Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79864ace88 | |||
| a2140b5ea8 | |||
| fa9fe6095b |
10 changed files with 1927 additions and 627 deletions
4
.env
4
.env
|
|
@ -1,4 +0,0 @@
|
|||
SERVER_NAME=delemaco
|
||||
SERVER_USER=root
|
||||
MAC_ADDRESS=b8:cb:29:a1:f3:88
|
||||
PORT=8080
|
||||
|
|
@ -2,3 +2,4 @@ SERVER_NAME=pippo
|
|||
SERVER_USER=root
|
||||
MAC_ADDRESS=aa:aa:aa:aa:aa:aa
|
||||
PORT=8080
|
||||
SHUTDOWN_PASSWORD="password"
|
||||
|
|
|
|||
212
.github/workflows/build.yml
vendored
212
.github/workflows/build.yml
vendored
|
|
@ -8,199 +8,131 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
name: Cross-compile for Raspberry Pi
|
||||
name: Build multi-arch Raspberry Pi binaries
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.20'
|
||||
go-version: "1.22"
|
||||
|
||||
- name: Build for ARM (Pi Zero, Pi 1 - ARMv6)
|
||||
- name: Build for ARMv6 (Pi Zero / Pi 1)
|
||||
run: |
|
||||
GOOS=linux GOARCH=arm GOARM=6 go build -ldflags="-s -w" -o wol-server-arm6
|
||||
GOOS=linux GOARCH=arm GOARM=6 \
|
||||
go build -ldflags="-s -w" -o wol-server-armv6
|
||||
|
||||
- name: Build for ARM64 (Pi Zero 2 / Pi 3 / Pi 4 / Pi 5)
|
||||
run: |
|
||||
GOOS=linux GOARCH=arm64 \
|
||||
go build -ldflags="-s -w" -o wol-server-arm64
|
||||
|
||||
- name: Create systemd service file
|
||||
run: |
|
||||
cat > wol-server.service << 'EOL'
|
||||
cat > wol-server.service << 'EOF'
|
||||
[Unit]
|
||||
Description=WOL Server Go Application
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=pi
|
||||
WorkingDirectory=/home/pi/wol-server
|
||||
ExecStart=/home/pi/wol-server/wol-server
|
||||
Type=simple
|
||||
User=loke
|
||||
WorkingDirectory=/home/loke/wol-server
|
||||
ExecStart=/home/loke/wol-server/wol-server
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOL
|
||||
EOF
|
||||
|
||||
- name: Create deployment script
|
||||
- name: Create install script
|
||||
run: |
|
||||
cat > install.sh << 'EOL'
|
||||
cat > install.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Installation directory
|
||||
INSTALL_DIR=~/wol-server
|
||||
INSTALL_DIR="$HOME/wol-server"
|
||||
|
||||
echo "Creating installation directory..."
|
||||
mkdir -p $INSTALL_DIR
|
||||
mkdir -p $INSTALL_DIR/templates
|
||||
mkdir -p "$INSTALL_DIR/templates"
|
||||
|
||||
echo "Installing application..."
|
||||
cp wol-server-arm6 $INSTALL_DIR/wol-server
|
||||
chmod +x $INSTALL_DIR/wol-server
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
armv6l|armv7l)
|
||||
BIN="wol-server-armv6"
|
||||
;;
|
||||
aarch64)
|
||||
BIN="wol-server-arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Installing template files..."
|
||||
cp -r templates/* $INSTALL_DIR/templates/
|
||||
echo "Detected architecture: $ARCH"
|
||||
echo "Using binary: $BIN"
|
||||
|
||||
echo "Installing system service..."
|
||||
cp "$BIN" "$INSTALL_DIR/wol-server"
|
||||
chmod +x "$INSTALL_DIR/wol-server"
|
||||
|
||||
echo "Installing templates..."
|
||||
cp -r templates/* "$INSTALL_DIR/templates/"
|
||||
|
||||
echo "Installing systemd service..."
|
||||
sudo cp wol-server.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable wol-server
|
||||
|
||||
# Install required dependencies
|
||||
echo "Installing dependencies..."
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y wakeonlan sshpass
|
||||
|
||||
# Start the service
|
||||
echo "Starting service..."
|
||||
sudo systemctl restart wol-server
|
||||
|
||||
echo "==========================================="
|
||||
echo "Installation complete!"
|
||||
echo "The WOL server is now running at http://$(hostname -I | awk '{print $1}'):8080"
|
||||
echo "WOL Server installed successfully!"
|
||||
echo "URL: http://$(hostname -I | awk '{print $1}'):8080"
|
||||
echo "==========================================="
|
||||
EOL
|
||||
EOF
|
||||
|
||||
chmod +x install.sh
|
||||
|
||||
- name: Create README with installation instructions
|
||||
- name: Create release package
|
||||
run: |
|
||||
cat > INSTALL.md << 'EOL'
|
||||
# WOL Server Installation Guide
|
||||
|
||||
This guide will help you install the Wake-on-LAN server on your Raspberry Pi.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Raspberry Pi running Raspberry Pi OS (Raspbian)
|
||||
- SSH access to your Pi
|
||||
- SCP or SFTP capability to transfer files
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Transfer Files to Raspberry Pi
|
||||
|
||||
**Option 1: Using SCP from your computer**
|
||||
|
||||
```bash
|
||||
# Replace with your Pi's IP address
|
||||
PI_IP=192.168.1.100
|
||||
|
||||
# Transfer the installation package
|
||||
scp wol-server.tar.gz pi@$PI_IP:~/
|
||||
```
|
||||
|
||||
**Option 2: Using SFTP client**
|
||||
|
||||
Use a tool like FileZilla, WinSCP, or Cyberduck to transfer the `wol-server.tar.gz` file to your Raspberry Pi.
|
||||
|
||||
### 2. SSH into your Raspberry Pi
|
||||
|
||||
```bash
|
||||
ssh pi@192.168.1.100
|
||||
```
|
||||
|
||||
### 3. Extract and Install
|
||||
|
||||
```bash
|
||||
# Navigate to home directory
|
||||
cd ~
|
||||
|
||||
# Extract the archive
|
||||
tar -xzf wol-server.tar.gz
|
||||
|
||||
# Run the installation script
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### 4. Test the Installation
|
||||
|
||||
Open a web browser and navigate to:
|
||||
```
|
||||
http://[your-pi-ip]:8080
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the service fails to start, check the logs:
|
||||
```bash
|
||||
sudo systemctl status wol-server
|
||||
```
|
||||
|
||||
If template errors occur, ensure the template files were copied correctly:
|
||||
```bash
|
||||
ls -la ~/wol-server/templates/
|
||||
```
|
||||
|
||||
## Manual Installation (if needed)
|
||||
|
||||
If you encounter issues with the automated install:
|
||||
|
||||
```bash
|
||||
# Create directories
|
||||
mkdir -p ~/wol-server/templates
|
||||
|
||||
# Copy files manually
|
||||
cp wol-server-arm6 ~/wol-server/wol-server
|
||||
chmod +x ~/wol-server/wol-server
|
||||
cp templates/* ~/wol-server/templates/
|
||||
sudo cp wol-server.service /etc/systemd/system/
|
||||
|
||||
# Install dependencies
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y wakeonlan sshpass
|
||||
|
||||
# Enable and start service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable wol-server
|
||||
sudo systemctl start wol-server
|
||||
```
|
||||
EOL
|
||||
|
||||
- name: Create all-in-one package
|
||||
run: |
|
||||
# Create a single package with everything needed
|
||||
mkdir -p package
|
||||
cp wol-server-arm6 package/
|
||||
cp wol-server-armv6 package/
|
||||
cp wol-server-arm64 package/
|
||||
cp wol-server.service package/
|
||||
cp install.sh package/
|
||||
cp -r templates package/
|
||||
cp INSTALL.md package/
|
||||
|
||||
# Create the tarball
|
||||
tar -czf wol-server.tar.gz -C package .
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v1
|
||||
- name: Create Forgejo Release
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
tag_name: v${{ github.run_number }}
|
||||
name: Release v${{ github.run_number }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
wol-server.tar.gz
|
||||
INSTALL.md
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="v${{ github.run_number }}"
|
||||
API_URL="${{ github.server_url }}/api/v1"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -s -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_URL}/repos/${REPO}/releases" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Release ${TAG}\", \"draft\": false, \"prerelease\": false}" \
|
||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
|
||||
# Upload asset
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=wol-server.tar.gz" \
|
||||
-F "attachment=@wol-server.tar.gz" > /dev/null
|
||||
|
||||
echo "Release ${TAG} created with asset wol-server.tar.gz"
|
||||
|
|
|
|||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
56
README.md
56
README.md
|
|
@ -7,7 +7,12 @@ A lightweight web-based Wake-on-LAN control panel designed for Raspberry Pi that
|
|||
## Features
|
||||
|
||||
- **Simple Web Interface**: Boot and shut down your server with a clean, responsive UI
|
||||
- **Status Monitoring**: Check if your target device is online
|
||||
- **Status Monitoring**: Check if your target device is online with auto-refreshing UI
|
||||
- **Scheduled Backup Window**: Configure automatic daily, bi-daily, weekly, or monthly server startup and shutdown for backup operations
|
||||
- **Auto Shutdown**: Shut down the server automatically at the end of the backup window
|
||||
- **Smart Shutdown Protection**: Only auto-shuts down servers that were started by the scheduler
|
||||
- **Passwordless Operation**: Uses environment variable for all shutdown operations
|
||||
- **Multiple Shutdown Methods**: Supports various SSH authentication methods for reliable automatic shutdown
|
||||
- **Raspberry Pi Optimized**: Built specifically for ARM processors found in all Raspberry Pi models
|
||||
- **Secure Shutdown**: Password-protected shutdown functionality
|
||||
- **Lightweight**: Minimal resource usage ideal for running on even the oldest Pi models
|
||||
|
|
@ -23,7 +28,7 @@ A lightweight web-based Wake-on-LAN control panel designed for Raspberry Pi that
|
|||
|
||||
### Option 1: One-Command Installation
|
||||
|
||||
1. **Download the latest release** on your local machine from the [Releases page](https://github.com/yourusername/wol-server/releases)
|
||||
1. **Download the latest release** on your local machine from the [Releases page](https://github.com/thisloke/wol-server/releases)
|
||||
|
||||
2. **Transfer the package to your Raspberry Pi** using SCP:
|
||||
```bash
|
||||
|
|
@ -102,6 +107,10 @@ nano ~/wol-server/.env
|
|||
| `SERVER_USER` | SSH username for shutdown | root |
|
||||
| `MAC_ADDRESS` | MAC address for Wake-on-LAN | aa:bb:cc:dd:ee:ff |
|
||||
| `PORT` | Web interface port | 8080 |
|
||||
| `SHUTDOWN_PASSWORD` | Password for all shutdown operations | None |
|
||||
| `REFRESH_INTERVAL` | UI refresh interval in seconds | 60 |
|
||||
|
||||
The scheduled backup window configuration is stored in `schedule.json` in the installation directory. It includes the start time, end time, and frequency settings.
|
||||
|
||||
After changing configuration, restart the service:
|
||||
```bash
|
||||
|
|
@ -122,6 +131,49 @@ http://your-pi-ip:8080
|
|||
- **Status Checking**: The interface shows the current status (Online/Offline)
|
||||
- **Booting**: Click the "Boot" button to send a WOL magic packet
|
||||
- **Shutting Down**: Click "Shutdown" and enter your SSH password when prompted
|
||||
- **Scheduled Backup Window**: Configure automatic server startup and shutdown on a regular schedule
|
||||
|
||||
#### Using Scheduled Backup Window
|
||||
|
||||
1. Click "Configure Schedule" in the Scheduled Backup Window section
|
||||
2. Enter your desired start and end times (in 24-hour format)
|
||||
3. Select a frequency (daily, every 2 days, weekly, or monthly)
|
||||
4. Optionally, enable "Auto Shutdown" (requires SHUTDOWN_PASSWORD in .env file)
|
||||
5. Click "Save Schedule" to activate
|
||||
6. The server will automatically boot at the start time and:
|
||||
- If auto shutdown is enabled: automatically shut down at the end time
|
||||
- If auto shutdown is disabled: remain on until manually shut down
|
||||
7. To modify an existing schedule, click "Edit Schedule"
|
||||
8. To disable, click "Disable Schedule" from the main interface
|
||||
|
||||
**Note:** All shutdown operations (manual and scheduled) use the SHUTDOWN_PASSWORD from your .env file.
|
||||
|
||||
#### Auto Shutdown Feature
|
||||
|
||||
The auto shutdown feature provides several advantages:
|
||||
- Saves power by ensuring the server only runs during scheduled backup periods
|
||||
- Prevents the server from accidentally remaining on after backups are complete
|
||||
- Fully automates the backup window process
|
||||
- Smart protection: only shuts down servers that were started by the scheduler
|
||||
|
||||
**Requirements for Shutdown Operations:**
|
||||
1. Set the `SHUTDOWN_PASSWORD` in your .env file
|
||||
2. The SSH server must be properly configured on the target server
|
||||
3. The user account specified in the configuration must have sudo privileges
|
||||
4. The password must be correct for the specified user account
|
||||
5. The server must allow password authentication via SSH
|
||||
|
||||
**Troubleshooting Shutdown Operations:**
|
||||
- If shutdown fails, check the logs for specific error messages
|
||||
- Ensure `sshpass` is installed on your Raspberry Pi (`sudo apt-get install sshpass`)
|
||||
- Verify you can manually SSH to the server with the provided credentials
|
||||
- Confirm the user has sudo privileges to run the shutdown command
|
||||
- Check if the server requires SSH key authentication instead of password
|
||||
- Verify the SHUTDOWN_PASSWORD is correctly set in your .env file
|
||||
|
||||
#### Auto-Refreshing UI
|
||||
|
||||
The web interface automatically refreshes every minute (or according to the REFRESH_INTERVAL setting) to show the current server status. This ensures you always see up-to-date information without having to manually refresh the page.
|
||||
|
||||
## Maintenance
|
||||
|
||||
|
|
|
|||
120
handlers.go
120
handlers.go
|
|
@ -4,6 +4,7 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Handle the root route - show status
|
||||
|
|
@ -13,6 +14,11 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Add cache control headers to prevent caching
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
|
||||
online := isServerOnline()
|
||||
status := "Online"
|
||||
color := "#4caf50" // Material green
|
||||
|
|
@ -21,6 +27,9 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
|
|||
color = "#d32f2f" // Material red
|
||||
}
|
||||
|
||||
// Get current schedule configuration
|
||||
scheduleConfig := GetScheduleConfig()
|
||||
|
||||
data := StatusData{
|
||||
Server: serverName,
|
||||
Status: status,
|
||||
|
|
@ -28,6 +37,9 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
|
|||
IsTestMode: runtime.GOOS == "darwin",
|
||||
AskPassword: false,
|
||||
ErrorMessage: "",
|
||||
Schedule: scheduleConfig,
|
||||
LastUpdated: time.Now().Format("2006-01-02 15:04:05"),
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
|
|
@ -52,6 +64,9 @@ func bootHandler(w http.ResponseWriter, r *http.Request) {
|
|||
Color: "#607d8b", // Material blue-gray
|
||||
IsTestMode: runtime.GOOS == "darwin",
|
||||
AskPassword: false,
|
||||
Schedule: GetScheduleConfig(),
|
||||
LastUpdated: time.Now().Format("2006-01-02 15:04:05"),
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "Failed to render template", http.StatusInternalServerError)
|
||||
|
|
@ -65,6 +80,9 @@ func bootHandler(w http.ResponseWriter, r *http.Request) {
|
|||
Color: "#4caf50", // Material green
|
||||
IsTestMode: runtime.GOOS == "darwin",
|
||||
AskPassword: false,
|
||||
Schedule: GetScheduleConfig(),
|
||||
LastUpdated: time.Now().Format("2006-01-02 15:04:05"),
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "Failed to render template", http.StatusInternalServerError)
|
||||
|
|
@ -85,6 +103,9 @@ func confirmShutdownHandler(w http.ResponseWriter, r *http.Request) {
|
|||
Color: "#d32f2f", // Material red
|
||||
IsTestMode: runtime.GOOS == "darwin",
|
||||
AskPassword: false,
|
||||
Schedule: GetScheduleConfig(),
|
||||
LastUpdated: time.Now().Format("2006-01-02 15:04:05"),
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "Failed to render template", http.StatusInternalServerError)
|
||||
|
|
@ -93,42 +114,53 @@ func confirmShutdownHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
// Check if shutdown password is set
|
||||
if shutdownPassword == "" {
|
||||
// Show error about missing password
|
||||
data := StatusData{
|
||||
Server: serverName,
|
||||
Status: "Online",
|
||||
Color: "#4caf50", // Material green
|
||||
IsTestMode: runtime.GOOS == "darwin",
|
||||
AskPassword: false,
|
||||
ConfirmShutdown: false,
|
||||
ErrorMessage: "SHUTDOWN_PASSWORD not set in environment. Please set it in the .env file.",
|
||||
Schedule: GetScheduleConfig(),
|
||||
LastUpdated: time.Now().Format("2006-01-02 15:04:05"),
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "Failed to render template", http.StatusInternalServerError)
|
||||
log.Printf("Template render error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Show confirmation dialog - we'll use the password from .env
|
||||
data := StatusData{
|
||||
Server: serverName,
|
||||
Status: "Online",
|
||||
Color: "#4caf50", // Material green
|
||||
IsTestMode: runtime.GOOS == "darwin",
|
||||
ConfirmShutdown: true,
|
||||
AskPassword: false,
|
||||
AskPassword: false, // Make sure we don't ask for password
|
||||
Schedule: GetScheduleConfig(),
|
||||
LastUpdated: time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
// Notify the user if password is not configured
|
||||
if shutdownPassword == "" {
|
||||
data.ErrorMessage = "SHUTDOWN_PASSWORD not set in environment. Shutdown may fail."
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "Failed to render template", http.StatusInternalServerError)
|
||||
log.Printf("Template render error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle password entry for shutdown
|
||||
func enterPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !isServerOnline() {
|
||||
// Server is already offline, redirect to home
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Show password entry dialog
|
||||
data := StatusData{
|
||||
Server: serverName,
|
||||
Status: "Online",
|
||||
Color: "#4caf50", // Material green
|
||||
IsTestMode: runtime.GOOS == "darwin",
|
||||
AskPassword: true,
|
||||
}
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "Failed to render template", http.StatusInternalServerError)
|
||||
log.Printf("Template render error: %v", err)
|
||||
}
|
||||
}
|
||||
// Handle shutdown confirmation without password
|
||||
// enterPasswordHandler function removed - we now use the password from .env directly
|
||||
|
||||
// Handle actual shutdown request
|
||||
func shutdownHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -138,25 +170,20 @@ func shutdownHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Parse form data to get password
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Printf("Error parsing form: %v", err)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Get password from form
|
||||
password := r.FormValue("password")
|
||||
|
||||
if password == "" {
|
||||
// Show password form again with error
|
||||
// Use the password from environment variable
|
||||
if shutdownPassword == "" {
|
||||
log.Printf("SHUTDOWN_PASSWORD not set in environment, cannot perform shutdown")
|
||||
// Show error message
|
||||
data := StatusData{
|
||||
Server: serverName,
|
||||
Status: "Online",
|
||||
Color: "#4caf50",
|
||||
IsTestMode: runtime.GOOS == "darwin",
|
||||
AskPassword: true,
|
||||
ErrorMessage: "Password cannot be empty",
|
||||
AskPassword: false,
|
||||
ErrorMessage: "SHUTDOWN_PASSWORD not set in environment",
|
||||
Schedule: GetScheduleConfig(),
|
||||
LastUpdated: time.Now().Format("2006-01-02 15:04:05"),
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "Failed to render template", http.StatusInternalServerError)
|
||||
|
|
@ -166,19 +193,22 @@ func shutdownHandler(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if isServerOnline() {
|
||||
// Shutdown the server
|
||||
err := shutdownServer(password)
|
||||
// Shutdown the server using the password from .env file
|
||||
err := shutdownServer(shutdownPassword)
|
||||
if err != nil {
|
||||
log.Printf("Error shutting down server: %v", err)
|
||||
|
||||
// Show password form again with error
|
||||
// Show error message
|
||||
data := StatusData{
|
||||
Server: serverName,
|
||||
Status: "Online",
|
||||
Color: "#4caf50",
|
||||
IsTestMode: runtime.GOOS == "darwin",
|
||||
AskPassword: true,
|
||||
ErrorMessage: "Failed to shutdown server. Please check your password.",
|
||||
AskPassword: false, // No longer asking for password
|
||||
ErrorMessage: "Failed to shutdown server. Please check the password in .env file.",
|
||||
Schedule: GetScheduleConfig(),
|
||||
LastUpdated: time.Now().Format("2006-01-02 15:04:05"),
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "Failed to render template", http.StatusInternalServerError)
|
||||
|
|
@ -194,6 +224,9 @@ func shutdownHandler(w http.ResponseWriter, r *http.Request) {
|
|||
Color: "#5d4037", // Material brown
|
||||
IsTestMode: runtime.GOOS == "darwin",
|
||||
AskPassword: false,
|
||||
Schedule: GetScheduleConfig(),
|
||||
LastUpdated: time.Now().Format("2006-01-02 15:04:05"),
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "Failed to render template", http.StatusInternalServerError)
|
||||
|
|
@ -207,6 +240,9 @@ func shutdownHandler(w http.ResponseWriter, r *http.Request) {
|
|||
Color: "#d32f2f", // Material red
|
||||
IsTestMode: runtime.GOOS == "darwin",
|
||||
AskPassword: false,
|
||||
Schedule: GetScheduleConfig(),
|
||||
LastUpdated: time.Now().Format("2006-01-02 15:04:05"),
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "Failed to render template", http.StatusInternalServerError)
|
||||
|
|
|
|||
391
main.go
391
main.go
|
|
@ -1,11 +1,16 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
|
@ -16,6 +21,7 @@ var (
|
|||
serverUser = "root" // SSH username
|
||||
macAddress = "aa:aa:aa:aa:aa:aa" // MAC address of the server
|
||||
port = "8080" // Port to listen on
|
||||
refreshInterval = 60 // UI refresh interval in seconds
|
||||
)
|
||||
|
||||
func loadEnvVariables() {
|
||||
|
|
@ -41,8 +47,15 @@ func loadEnvVariables() {
|
|||
port = envPort
|
||||
}
|
||||
|
||||
log.Printf("Configuration loaded: SERVER_NAME=%s, SERVER_USER=%s, MAC_ADDRESS=%s, PORT=%s",
|
||||
serverName, serverUser, macAddress, port)
|
||||
// Load refresh interval if set
|
||||
if envRefresh := os.Getenv("REFRESH_INTERVAL"); envRefresh != "" {
|
||||
if val, err := strconv.Atoi(envRefresh); err == nil && val > 0 {
|
||||
refreshInterval = val
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Configuration loaded: SERVER_NAME=%s, SERVER_USER=%s, MAC_ADDRESS=%s, PORT=%s, REFRESH=%d",
|
||||
serverName, serverUser, macAddress, port, refreshInterval)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
|
@ -54,13 +67,24 @@ func main() {
|
|||
log.Fatalf("Failed to setup template: %v", err)
|
||||
}
|
||||
|
||||
// Verify schedule configuration and clean up stale schedule data if needed
|
||||
verifyScheduleConfig()
|
||||
|
||||
// Setup a ticker to check schedule and perform actions
|
||||
go runScheduleChecker()
|
||||
|
||||
// Register route handlers
|
||||
http.HandleFunc("/", indexHandler)
|
||||
http.HandleFunc("/boot", bootHandler)
|
||||
http.HandleFunc("/confirm-shutdown", confirmShutdownHandler)
|
||||
http.HandleFunc("/enter-password", enterPasswordHandler)
|
||||
// Password is now taken directly from .env file
|
||||
http.HandleFunc("/shutdown", shutdownHandler)
|
||||
|
||||
// Schedule API endpoints
|
||||
http.HandleFunc("/api/schedule", scheduleHandler)
|
||||
// API shutdown endpoint
|
||||
http.HandleFunc("/api/shutdown", apiShutdownHandler)
|
||||
|
||||
// Start the server
|
||||
listenAddr := fmt.Sprintf(":%s", port)
|
||||
log.Printf("Starting WOL Server on http://localhost%s", listenAddr)
|
||||
|
|
@ -73,3 +97,364 @@ func main() {
|
|||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// API Shutdown handler - shuts down the server with password from environment
|
||||
func apiShutdownHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Add cache control headers to prevent caching
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
// Set content type for JSON response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Only allow POST requests
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "Method not allowed. Use POST.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if shutdown password is available in environment
|
||||
if shutdownPassword == "" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "SHUTDOWN_PASSWORD not set in environment",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if server is online before attempting shutdown
|
||||
if !isServerOnline() {
|
||||
// Server is already offline
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "Server is already offline",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Try to shut down the server using the password from environment
|
||||
err := shutdownServer(shutdownPassword)
|
||||
if err != nil {
|
||||
// Shutdown command failed
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "Failed to shutdown server: " + err.Error(),
|
||||
})
|
||||
log.Printf("API shutdown failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Shutdown initiated successfully
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Server shutdown initiated",
|
||||
})
|
||||
log.Printf("API shutdown successful")
|
||||
}
|
||||
|
||||
// Handle schedule API requests
|
||||
func scheduleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Set content type
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Handle GET request - return current schedule
|
||||
if r.Method == "GET" {
|
||||
data, err := json.Marshal(GetScheduleConfig())
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error": "Failed to marshal schedule data: %v"}`, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle POST request - update schedule
|
||||
if r.Method == "POST" {
|
||||
var newConfig ScheduleConfig
|
||||
err := json.NewDecoder(r.Body).Decode(&newConfig)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error": "Failed to parse request body: %v"}`, err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the schedule data
|
||||
if newConfig.Enabled {
|
||||
// Validate time format (HH:MM)
|
||||
_, err = time.Parse("15:04", newConfig.StartTime)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error": "Invalid start time format. Use 24-hour format (HH:MM)"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = time.Parse("15:04", newConfig.EndTime)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error": "Invalid end time format. Use 24-hour format (HH:MM)"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate frequency
|
||||
validFrequencies := map[string]bool{
|
||||
"daily": true,
|
||||
"every2days": true,
|
||||
"weekly": true,
|
||||
"monthly": true,
|
||||
}
|
||||
|
||||
if !validFrequencies[newConfig.Frequency] {
|
||||
http.Error(w, `{"error": "Invalid frequency. Use 'daily', 'every2days', 'weekly', or 'monthly'"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset lastRun if it wasn't set
|
||||
if newConfig.LastRun == "" {
|
||||
newConfig.LastRun = ""
|
||||
}
|
||||
|
||||
// If auto shutdown is enabled, make sure we have a password in env
|
||||
if newConfig.AutoShutdown && shutdownPassword == "" {
|
||||
http.Error(w, `{"error": "SHUTDOWN_PASSWORD not set in environment. Please set it before enabling auto-shutdown"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if SSH connection can be established with the password
|
||||
if newConfig.AutoShutdown && shutdownPassword != "" {
|
||||
log.Printf("Testing SSH connection to %s with provided password", serverName)
|
||||
|
||||
// We'll just check if the server is reachable first
|
||||
if !isServerOnline() {
|
||||
log.Printf("Server %s is not online, can't test SSH connection", serverName)
|
||||
} else {
|
||||
// Try to run a harmless command to test SSH connection
|
||||
cmd := exec.Command("sshpass", "-p", shutdownPassword, "ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "ConnectTimeout=5",
|
||||
fmt.Sprintf("%s@%s", serverUser, serverName),
|
||||
"echo", "SSH connection test successful")
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("SSH connection test failed: %v - %s", err, stderr.String())
|
||||
// We don't prevent saving the config even if test fails
|
||||
// Just log a warning for now
|
||||
log.Printf("WARNING: Auto shutdown may not work with the provided password")
|
||||
} else {
|
||||
log.Printf("SSH connection test successful")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save the new configuration
|
||||
err = UpdateScheduleConfig(newConfig)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error": "Failed to save schedule config: %v"}`, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return the updated config
|
||||
data, err := json.Marshal(GetScheduleConfig())
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error": "Failed to marshal schedule data: %v"}`, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Method not allowed
|
||||
http.Error(w, `{"error": "Method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// Verify and clean up schedule configuration
|
||||
func verifyScheduleConfig() {
|
||||
// If schedule is enabled, validate all required fields
|
||||
if scheduleConfig.Enabled {
|
||||
log.Println("Verifying schedule configuration...")
|
||||
log.Printf("Current config: StartTime=%s, EndTime=%s, Frequency=%s, AutoShutdown=%v",
|
||||
scheduleConfig.StartTime, scheduleConfig.EndTime, scheduleConfig.Frequency, scheduleConfig.AutoShutdown)
|
||||
|
||||
// Check for valid time formats
|
||||
_, startErr := time.Parse("15:04", scheduleConfig.StartTime)
|
||||
_, endErr := time.Parse("15:04", scheduleConfig.EndTime)
|
||||
|
||||
if startErr != nil || endErr != nil || scheduleConfig.StartTime == "" || scheduleConfig.EndTime == "" {
|
||||
log.Println("Warning: Invalid time format in schedule configuration, disabling schedule")
|
||||
scheduleConfig.Enabled = false
|
||||
UpdateScheduleConfig(scheduleConfig)
|
||||
return
|
||||
}
|
||||
|
||||
// Immediately check if we need to boot ONLY at exact start time
|
||||
now := time.Now()
|
||||
currentTimeStr := now.Format("15:04")
|
||||
|
||||
// Check ONLY if current time EXACTLY matches start time
|
||||
if currentTimeStr == scheduleConfig.StartTime && ShouldRunToday(now) {
|
||||
log.Printf("STARTUP MATCH: Current time %s matches start time EXACTLY, attempting boot", currentTimeStr)
|
||||
if !isServerOnline() {
|
||||
sendWakeOnLAN()
|
||||
// Mark that the server was started by the scheduler
|
||||
scheduleConfig.StartedBySchedule = true
|
||||
scheduleConfig.LastRun = now.Format(time.RFC3339)
|
||||
UpdateScheduleConfig(scheduleConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for valid frequency
|
||||
validFrequencies := map[string]bool{
|
||||
"daily": true,
|
||||
"every2days": true,
|
||||
"weekly": true,
|
||||
"monthly": true,
|
||||
}
|
||||
|
||||
if !validFrequencies[scheduleConfig.Frequency] {
|
||||
log.Println("Warning: Invalid frequency in schedule configuration, setting to daily")
|
||||
scheduleConfig.Frequency = "daily"
|
||||
UpdateScheduleConfig(scheduleConfig)
|
||||
}
|
||||
|
||||
log.Printf("Schedule configuration verified: Start=%s, End=%s, Frequency=%s",
|
||||
scheduleConfig.StartTime, scheduleConfig.EndTime, scheduleConfig.Frequency)
|
||||
}
|
||||
}
|
||||
|
||||
// Run a periodic check of schedule and take appropriate actions
|
||||
func runScheduleChecker() {
|
||||
// Define the checkScheduleOnce function
|
||||
checkScheduleOnce := func() {
|
||||
// Only check exact times for schedule actions, don't use window logic
|
||||
now := time.Now()
|
||||
currentTimeStr := now.Format("15:04")
|
||||
serverIsOn := isServerOnline()
|
||||
|
||||
// Log schedule status (debug level)
|
||||
if scheduleConfig.Enabled {
|
||||
log.Printf("Schedule check: Current=%s, Start=%s, End=%s, LastRun=%s",
|
||||
currentTimeStr, scheduleConfig.StartTime, scheduleConfig.EndTime, scheduleConfig.LastRun)
|
||||
|
||||
// Only act at exact start or end times
|
||||
// EXACT START TIME MATCH - Try to boot server
|
||||
if currentTimeStr == scheduleConfig.StartTime && !serverIsOn && ShouldRunToday(now) {
|
||||
log.Println("EXACT START TIME: Initiating boot sequence...")
|
||||
|
||||
// Try multiple times to boot with small delays between attempts
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
log.Printf("Boot attempt %d/3", attempt)
|
||||
err := sendWakeOnLAN()
|
||||
if err != nil {
|
||||
log.Printf("Error booting server from schedule: %v", err)
|
||||
} else {
|
||||
log.Println("Schedule: Boot command sent successfully")
|
||||
// Mark that server was started by scheduler
|
||||
scheduleConfig.StartedBySchedule = true
|
||||
scheduleConfig.LastRun = now.Format(time.RFC3339)
|
||||
UpdateScheduleConfig(scheduleConfig)
|
||||
}
|
||||
|
||||
// Check if server came online
|
||||
time.Sleep(3 * time.Second) // Extended wait time for boot check
|
||||
if isServerOnline() {
|
||||
log.Println("Server successfully booted!")
|
||||
break
|
||||
}
|
||||
|
||||
// Short delay before next attempt
|
||||
if attempt < 3 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
// EXACT END TIME MATCH - Try to shutdown server
|
||||
} else if currentTimeStr == scheduleConfig.EndTime && serverIsOn {
|
||||
// Check if auto-shutdown is enabled
|
||||
if scheduleConfig.AutoShutdown && shutdownPassword != "" && scheduleConfig.StartedBySchedule {
|
||||
log.Println("EXACT END TIME: Attempting auto-shutdown")
|
||||
|
||||
// Try multiple times to shut down the server
|
||||
var shutdownSuccessful bool
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
log.Printf("Auto shutdown attempt %d/3", attempt)
|
||||
err := shutdownServer(shutdownPassword)
|
||||
if err != nil {
|
||||
log.Printf("Auto shutdown attempt %d failed: %v", attempt, err)
|
||||
if attempt < 3 {
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Auto shutdown initiated successfully on attempt %d", attempt)
|
||||
shutdownSuccessful = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !shutdownSuccessful {
|
||||
log.Printf("All auto shutdown attempts failed")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No action at non-exact times, just log status
|
||||
if serverIsOn && scheduleConfig.StartedBySchedule && currentTimeStr > scheduleConfig.EndTime {
|
||||
log.Printf("Server is still online after end time %s - waiting for next exact end time match", scheduleConfig.EndTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update last run timestamp if we've passed the end time
|
||||
// This helps track when the schedule was last active
|
||||
currentConfig := GetScheduleConfig()
|
||||
nowTime := time.Now()
|
||||
currentTimeString := nowTime.Format("15:04")
|
||||
if currentConfig.Enabled && currentTimeString > currentConfig.EndTime && currentConfig.LastRun != "" {
|
||||
lastRun, err := time.Parse(time.RFC3339, currentConfig.LastRun)
|
||||
if err == nil {
|
||||
// If it's been more than a day since the last update, reset the timestamp
|
||||
// This allows the schedule to run again based on frequency
|
||||
if time.Since(lastRun) > 24*time.Hour {
|
||||
log.Println("Schedule: Resetting last run timestamp for next scheduled run")
|
||||
currentConfig.LastRun = ""
|
||||
UpdateScheduleConfig(currentConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use a slightly shorter interval for more responsive scheduling
|
||||
// First check immediately at startup
|
||||
checkScheduleOnce()
|
||||
|
||||
// Then set up regular checks
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Println("Schedule checker started - checking every 5 seconds")
|
||||
log.Printf("Current schedule: enabled=%v, startTime=%s, endTime=%s, frequency=%s, autoShutdown=%v",
|
||||
scheduleConfig.Enabled, scheduleConfig.StartTime, scheduleConfig.EndTime, scheduleConfig.Frequency, scheduleConfig.AutoShutdown)
|
||||
|
||||
for {
|
||||
func() {
|
||||
// Recover from any panics that might occur in the schedule checker
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("Recovered from panic in schedule checker: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
checkScheduleOnce()
|
||||
}()
|
||||
|
||||
// Wait for next tick
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
schedule.json
Normal file
9
schedule.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"enabled": true,
|
||||
"startTime": "13:46",
|
||||
"endTime": "13:49",
|
||||
"frequency": "daily",
|
||||
"lastRun": "2025-09-05T13:46:41+02:00",
|
||||
"autoShutdown": true,
|
||||
"startedBySchedule": true
|
||||
}
|
||||
|
|
@ -1,12 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="refresh"
|
||||
content="{{if .RefreshInterval}}{{.RefreshInterval}}{{else}}60{{end}}"
|
||||
/>
|
||||
<title>Server Status: {{.Server}}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: {{.Color}};
|
||||
|
|
@ -18,6 +25,8 @@
|
|||
--success-color: #4caf50;
|
||||
--modal-bg: rgba(0, 0, 0, 0.85);
|
||||
--error-color: #ff6b6b;
|
||||
--info-color: #2196f3;
|
||||
--warning-color: #ff9800;
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -62,6 +71,60 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(5px);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.schedule-header {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.schedule-status {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.schedule-status.active {
|
||||
background-color: rgba(76, 175, 80, 0.2);
|
||||
border: 1px solid rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.schedule-status.inactive {
|
||||
background-color: rgba(244, 67, 54, 0.2);
|
||||
border: 1px solid rgba(244, 67, 54, 0.5);
|
||||
}
|
||||
|
||||
.schedule-info {
|
||||
margin-bottom: 25px;
|
||||
padding: 15px;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.schedule-info p {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.schedule-info p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
|
|
@ -155,6 +218,14 @@
|
|||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' viewBox='0 -960 960 960' width='24' fill='white'%3E%3Cpath d='M480-120q-151 0-255.5-104.5T120-480q0-138 89-239t219-120q20-3 33.5 9.5T480-797q4 20-9 35.5T437-748q-103 12-170 87t-67 181q0 124 88 212t212 88q124 0 212-88t88-212q0-109-69.5-184.5T564-748q-21-3-31.5-19T525-798q3-20 19-30.5t35-6.5q136 19 228.5 122.5T900-480q0 150-104.5 255T480-120Zm0-360Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.button.schedule::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' viewBox='0 -960 960 960' width='24' fill='white'%3E%3Cpath d='M480-120q-75 0-140.5-28.5t-114-77q-48.5-48.5-77-114T120-480q0-75 28.5-140.5t77-114q48.5-48.5 114-77T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm0-82q117 0 198.5-81.5T760-482q0-117-81.5-198.5T480-762q-117 0-198.5 81.5T200-482q0 117 81.5 198.5T480-202Zm0-280Zm-40 202-170-170 56-56 114 113 226-226 56 56-282 283Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.button.disable::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' viewBox='0 -960 960 960' width='24' fill='white'%3E%3Cpath d='M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Zm-40 120-56-56 40-40-40-40 56-56 40 40 40-40 56 56-40 40 40 40-56 56-40-40-40 40Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.button.submit::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24' viewBox='0 -960 960 960' width='24' fill='white'%3E%3Cpath d='M382-240 154-468l57-57 171 171 367-367 57 57-424 424Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
|
@ -253,12 +324,44 @@
|
|||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
input[type="datetime-local"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
accent-color: var(--success-color);
|
||||
}
|
||||
|
||||
.form-help {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
margin-top: 5px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.shutdown-warning {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
background-color: rgba(255, 152, 0, 0.2);
|
||||
border: 1px solid rgba(255, 152, 0, 0.5);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.9rem;
|
||||
|
|
@ -266,6 +369,25 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
border-radius: 50px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.badge.active {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.inactive {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 600px) {
|
||||
.card {
|
||||
|
|
@ -343,16 +465,146 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scheduled Backup Window -->
|
||||
<div class="schedule-card">
|
||||
<h2 class="schedule-header">Scheduled Backup Window</h2>
|
||||
|
||||
{{if .Schedule.Enabled}}
|
||||
<div class="schedule-status active">
|
||||
Automatic scheduled backup is active
|
||||
</div>
|
||||
<div class="schedule-info">
|
||||
<p>
|
||||
<span>Start Time:</span>
|
||||
<span>{{.Schedule.StartTime}}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>End Time:</span>
|
||||
<span>{{.Schedule.EndTime}}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>Frequency:</span>
|
||||
<span>{{.Schedule.Frequency}}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>Auto Shutdown:</span>
|
||||
<span
|
||||
>{{if .Schedule.AutoShutdown}}
|
||||
<span class="badge active">Enabled</span>
|
||||
{{else}}
|
||||
<span class="badge inactive">Disabled</span>
|
||||
{{end}}</span
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<span>Last Update:</span>
|
||||
<span>{{.LastUpdated}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button id="editSchedule" class="button schedule">
|
||||
Edit Schedule
|
||||
</button>
|
||||
<button id="disableSchedule" class="button disable">
|
||||
Disable Schedule
|
||||
</button>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="schedule-status inactive">No active backup schedule</div>
|
||||
<div class="controls">
|
||||
<button id="enableSchedule" class="button schedule">
|
||||
Configure Schedule
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .IsTestMode}}
|
||||
<div class="test-panel">
|
||||
<div class="test-note">
|
||||
Running on macOS. Commands will be executed using the provided password.
|
||||
Running on macOS. Commands will be executed using the provided
|
||||
password.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="footer">
|
||||
Wake-on-LAN Server Control Panel
|
||||
<div class="footer">Wake-on-LAN Server Control Panel</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Configuration Modal -->
|
||||
<div id="scheduleModal" class="modal-overlay" style="display: none">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">Configure Backup Schedule</div>
|
||||
<div class="modal-body">
|
||||
<form id="scheduleForm">
|
||||
<div class="form-group">
|
||||
<label for="startTime" class="form-label">Start Time:</label>
|
||||
<input
|
||||
type="time"
|
||||
id="startTime"
|
||||
name="startTime"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="endTime" class="form-label">End Time:</label>
|
||||
<input
|
||||
type="time"
|
||||
id="endTime"
|
||||
name="endTime"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="frequency" class="form-label">Frequency:</label>
|
||||
<select id="frequency" name="frequency" class="form-input">
|
||||
<option value="daily">Every day</option>
|
||||
<option value="every2days">Every 2 days</option>
|
||||
<option value="weekly">Once a week</option>
|
||||
<option value="monthly">Once a month</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="checkbox-wrapper">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoShutdown"
|
||||
name="autoShutdown"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
<label for="autoShutdown"
|
||||
>Enable automatic shutdown at end time</label
|
||||
>
|
||||
</div>
|
||||
<div class="form-help">
|
||||
When enabled, the server will automatically shut down at the
|
||||
specified end time. Make sure SSH access is properly configured.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-help shutdown-warning">
|
||||
<strong>Note:</strong> To use automatic shutdown, add
|
||||
SHUTDOWN_PASSWORD to your .env file. The server will only be shut
|
||||
down automatically if it was started by the scheduler.
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="scheduleError"
|
||||
class="error-message"
|
||||
style="display: none"
|
||||
></div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="cancelSchedule" class="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="button submit">Save Schedule</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -361,42 +613,161 @@
|
|||
<div class="modal-content">
|
||||
<div class="modal-header">Confirm Shutdown</div>
|
||||
<div class="modal-body">
|
||||
Are you sure you want to shut down <strong>{{.Server}}</strong>?<br>
|
||||
Are you sure you want to shut down <strong>{{.Server}}</strong>?<br />
|
||||
This will immediately power off the server.
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<a href="/" class="button">Cancel</a>
|
||||
<a href="/enter-password" class="button danger">Yes, Continue</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .AskPassword}}
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">Enter Password</div>
|
||||
<div class="modal-body">
|
||||
Please enter the password for <strong>{{.Server}}</strong> to shutdown the server.
|
||||
|
||||
<form action="/shutdown" method="POST">
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Password:</label>
|
||||
<input type="password" id="password" name="password" class="form-input" autofocus>
|
||||
</div>
|
||||
|
||||
{{if .ErrorMessage}}
|
||||
<div class="error-message">{{.ErrorMessage}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="modal-actions">
|
||||
<a href="/" class="button">Cancel</a>
|
||||
<button type="submit" class="button submit">Submit</button>
|
||||
</div>
|
||||
<form action="/shutdown" method="POST" style="display: inline">
|
||||
<button type="submit" class="button danger">Yes, Shutdown</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}} {{if .ErrorMessage}}
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">Error</div>
|
||||
<div class="modal-body">
|
||||
<div class="error-message">{{.ErrorMessage}}</div>
|
||||
|
||||
<div class="shutdown-warning" style="margin-top: 20px">
|
||||
<p>
|
||||
To use the shutdown feature, add SHUTDOWN_PASSWORD to your .env
|
||||
file.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<a href="/" class="button">Back to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
// Schedule modal handling
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const scheduleModal = document.getElementById("scheduleModal");
|
||||
const enableScheduleBtn = document.getElementById("enableSchedule");
|
||||
const disableScheduleBtn = document.getElementById("disableSchedule");
|
||||
const cancelScheduleBtn = document.getElementById("cancelSchedule");
|
||||
const scheduleForm = document.getElementById("scheduleForm");
|
||||
const scheduleError = document.getElementById("scheduleError");
|
||||
|
||||
// Show schedule modal
|
||||
if (enableScheduleBtn) {
|
||||
enableScheduleBtn.addEventListener("click", function () {
|
||||
scheduleModal.style.display = "flex";
|
||||
});
|
||||
}
|
||||
|
||||
// Handle auto shutdown checkbox
|
||||
const autoShutdownCheckbox = document.getElementById("autoShutdown");
|
||||
|
||||
// Edit schedule button
|
||||
const editScheduleBtn = document.getElementById("editSchedule");
|
||||
if (editScheduleBtn) {
|
||||
editScheduleBtn.addEventListener("click", function() {
|
||||
// Pre-fill form with current values
|
||||
document.getElementById("startTime").value = "{{.Schedule.StartTime}}";
|
||||
document.getElementById("endTime").value = "{{.Schedule.EndTime}}";
|
||||
document.getElementById("frequency").value = "{{.Schedule.Frequency}}";
|
||||
document.getElementById("autoShutdown").checked = {{.Schedule.AutoShutdown}};
|
||||
|
||||
scheduleModal.style.display = "flex";
|
||||
});
|
||||
}
|
||||
|
||||
// Hide schedule modal
|
||||
if (cancelScheduleBtn) {
|
||||
cancelScheduleBtn.addEventListener("click", function () {
|
||||
scheduleModal.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
// Handle disable schedule
|
||||
if (disableScheduleBtn) {
|
||||
disableScheduleBtn.addEventListener("click", function () {
|
||||
// Disable schedule via API
|
||||
fetch("/api/schedule", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: false,
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
frequency: "daily",
|
||||
autoShutdown: false,
|
||||
shutdownPassword: "",
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
// Refresh page to show updated status
|
||||
window.location.reload();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
alert("Failed to disable schedule. Please try again.");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle schedule form submission
|
||||
if (scheduleForm) {
|
||||
scheduleForm.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
// We no longer use these variables - validation happens with startTime/endTime below
|
||||
|
||||
// Get form values
|
||||
const startTime = document.getElementById("startTime").value;
|
||||
const endTime = document.getElementById("endTime").value;
|
||||
const frequency = document.getElementById("frequency").value;
|
||||
|
||||
// Simple validation for times
|
||||
if (!startTime || !endTime) {
|
||||
scheduleError.textContent =
|
||||
"Please enter both start and end times";
|
||||
scheduleError.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
// Get auto shutdown settings
|
||||
const autoShutdown =
|
||||
document.getElementById("autoShutdown").checked;
|
||||
|
||||
// Submit to API
|
||||
fetch("/api/schedule", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
frequency: frequency,
|
||||
autoShutdown: autoShutdown,
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
// Refresh page to show updated status
|
||||
window.location.reload();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
scheduleError.textContent =
|
||||
"Failed to save schedule. Please try again.";
|
||||
scheduleError.style.display = "block";
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
527
utils.go
527
utils.go
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
|
|
@ -9,8 +10,20 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ScheduleConfig holds the backup window schedule settings
|
||||
type ScheduleConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
StartTime string `json:"startTime"` // Format: "HH:MM" (24-hour)
|
||||
EndTime string `json:"endTime"` // Format: "HH:MM" (24-hour)
|
||||
Frequency string `json:"frequency"` // "daily", "every2days", "weekly", "monthly"
|
||||
LastRun string `json:"lastRun"` // ISO8601 format - when the schedule last ran
|
||||
AutoShutdown bool `json:"autoShutdown"` // Whether to automatically shut down at end time
|
||||
StartedBySchedule bool `json:"startedBySchedule"` // Whether server was started by scheduler
|
||||
}
|
||||
|
||||
// StatusData holds data for the HTML template
|
||||
type StatusData struct {
|
||||
Server string
|
||||
|
|
@ -20,9 +33,15 @@ type StatusData struct {
|
|||
ConfirmShutdown bool
|
||||
AskPassword bool
|
||||
ErrorMessage string
|
||||
Schedule ScheduleConfig
|
||||
LastUpdated string
|
||||
RefreshInterval int
|
||||
}
|
||||
|
||||
var tmpl *template.Template
|
||||
var scheduleConfig ScheduleConfig
|
||||
var scheduleConfigPath = "schedule.json"
|
||||
var shutdownPassword string // Will be loaded from environment
|
||||
|
||||
// Setup the HTML template
|
||||
func setupTemplate() error {
|
||||
|
|
@ -49,12 +68,362 @@ func setupTemplate() error {
|
|||
return fmt.Errorf("failed to parse template: %v", err)
|
||||
}
|
||||
|
||||
// Load schedule config
|
||||
err = loadScheduleConfig()
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load schedule config: %v", err)
|
||||
// Continue with default (empty) schedule config
|
||||
}
|
||||
|
||||
// Check for required system tools
|
||||
checkRequiredTools()
|
||||
|
||||
// Load shutdown password from environment
|
||||
shutdownPassword = os.Getenv("SHUTDOWN_PASSWORD")
|
||||
if shutdownPassword == "" {
|
||||
log.Println("SHUTDOWN_PASSWORD not set in environment. Automatic shutdown will be disabled.")
|
||||
} else {
|
||||
log.Println("SHUTDOWN_PASSWORD loaded from environment")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if required system tools are available
|
||||
func checkRequiredTools() {
|
||||
// Check for wakeonlan command
|
||||
if _, err := exec.LookPath("wakeonlan"); err != nil {
|
||||
if _, err := exec.LookPath("etherwake"); err != nil {
|
||||
if _, err := exec.LookPath("wol"); err != nil {
|
||||
log.Printf("WARNING: No Wake-on-LAN tools found. Please install wakeonlan, etherwake, or wol package.")
|
||||
log.Printf("Installation instructions:")
|
||||
log.Printf(" - For Debian/Ubuntu: sudo apt-get install wakeonlan")
|
||||
log.Printf(" - For macOS: brew install wakeonlan")
|
||||
log.Printf(" - For Windows: Download from https://www.depicus.com/wake-on-lan/wake-on-lan-cmd")
|
||||
} else {
|
||||
log.Printf("Using 'wol' for Wake-on-LAN functionality")
|
||||
}
|
||||
} else {
|
||||
log.Printf("Using 'etherwake' for Wake-on-LAN functionality")
|
||||
}
|
||||
} else {
|
||||
log.Printf("Found 'wakeonlan' command for Wake-on-LAN functionality")
|
||||
}
|
||||
|
||||
// Check for ping command (needed for server status checks)
|
||||
if _, err := exec.LookPath("ping"); err != nil {
|
||||
log.Printf("WARNING: 'ping' command not found. Server status checks may fail.")
|
||||
}
|
||||
}
|
||||
|
||||
// Load schedule configuration from file
|
||||
func loadScheduleConfig() error {
|
||||
// Check if config file exists
|
||||
if _, err := os.Stat(scheduleConfigPath); os.IsNotExist(err) {
|
||||
// Create default config
|
||||
scheduleConfig = ScheduleConfig{
|
||||
Enabled: false,
|
||||
StartTime: "",
|
||||
EndTime: "",
|
||||
Frequency: "daily",
|
||||
LastRun: "",
|
||||
AutoShutdown: false,
|
||||
StartedBySchedule: false,
|
||||
}
|
||||
// Save default config
|
||||
return saveScheduleConfig()
|
||||
}
|
||||
|
||||
// Read the file
|
||||
data, err := os.ReadFile(scheduleConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read schedule config file: %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal JSON data
|
||||
err = json.Unmarshal(data, &scheduleConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse schedule config: %v", err)
|
||||
}
|
||||
|
||||
// Log loaded configuration for debugging
|
||||
log.Printf("Loaded schedule config: Enabled=%v, StartTime=%s, EndTime=%s, Frequency=%s",
|
||||
scheduleConfig.Enabled, scheduleConfig.StartTime, scheduleConfig.EndTime, scheduleConfig.Frequency)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save schedule configuration to file
|
||||
func saveScheduleConfig() error {
|
||||
data, err := json.MarshalIndent(scheduleConfig, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal schedule config: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(scheduleConfigPath, data, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save schedule config: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetScheduleConfig returns the current schedule config
|
||||
func GetScheduleConfig() ScheduleConfig {
|
||||
return scheduleConfig
|
||||
}
|
||||
|
||||
// UpdateScheduleConfig updates the schedule configuration
|
||||
func UpdateScheduleConfig(newConfig ScheduleConfig) error {
|
||||
scheduleConfig = newConfig
|
||||
return saveScheduleConfig()
|
||||
}
|
||||
|
||||
// CheckSchedule checks if server should be on/off based on schedule
|
||||
func CheckSchedule() (shouldBeOn bool) {
|
||||
// If schedule is not enabled, do nothing
|
||||
if !scheduleConfig.Enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
// If start time or end time is empty, the schedule is not properly configured
|
||||
if scheduleConfig.StartTime == "" || scheduleConfig.EndTime == "" {
|
||||
log.Printf("Schedule configuration incomplete: StartTime=%s, EndTime=%s",
|
||||
scheduleConfig.StartTime, scheduleConfig.EndTime)
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
|
||||
// Get current time as just hours and minutes for direct string comparison first
|
||||
currentTimeStr := now.Format("15:04")
|
||||
|
||||
// Log the exact time comparison we're doing
|
||||
log.Printf("Schedule debug: Current=%s, Start=%s, End=%s, LastRun=%s",
|
||||
currentTimeStr, scheduleConfig.StartTime, scheduleConfig.EndTime, scheduleConfig.LastRun)
|
||||
|
||||
// Parse start time with proper error handling
|
||||
startTime, err := time.Parse("2006-01-02 15:04", fmt.Sprintf("%s %s", today, scheduleConfig.StartTime))
|
||||
if err != nil {
|
||||
log.Printf("Error parsing start time '%s': %v", scheduleConfig.StartTime, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse end time with proper error handling
|
||||
endTime, err := time.Parse("2006-01-02 15:04", fmt.Sprintf("%s %s", today, scheduleConfig.EndTime))
|
||||
if err != nil {
|
||||
log.Printf("Error parsing end time '%s': %v", scheduleConfig.EndTime, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// If end time is before start time, it means the window spans to the next day
|
||||
if endTime.Before(startTime) {
|
||||
endTime = endTime.AddDate(0, 0, 1)
|
||||
|
||||
// Special case: if we're after midnight but before the end time
|
||||
// we need to adjust the start time to be from yesterday
|
||||
if now.Before(endTime) && now.After(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())) {
|
||||
startTime = startTime.AddDate(0, 0, -1)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the schedule should run today based on frequency
|
||||
// Check if we're in the schedule window
|
||||
if !ShouldRunToday(now) {
|
||||
log.Printf("Schedule is active but not set to run today based on frequency: %s", scheduleConfig.Frequency)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for auto shutdown at end time
|
||||
if currentTimeStr == scheduleConfig.EndTime {
|
||||
if scheduleConfig.AutoShutdown && shutdownPassword != "" && isServerOnline() {
|
||||
// Only shut down if the server was started by the scheduler
|
||||
if scheduleConfig.StartedBySchedule {
|
||||
log.Printf("Auto shutdown triggered at schedule end time %s", scheduleConfig.EndTime)
|
||||
|
||||
// Try up to 3 times to shut down the server
|
||||
var shutdownSuccessful bool
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
log.Printf("Auto shutdown attempt %d/3", attempt)
|
||||
err := shutdownServer(shutdownPassword)
|
||||
if err != nil {
|
||||
log.Printf("Auto shutdown attempt %d failed: %v", attempt, err)
|
||||
if attempt < 3 {
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Auto shutdown initiated successfully on attempt %d", attempt)
|
||||
shutdownSuccessful = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !shutdownSuccessful {
|
||||
log.Printf("All auto shutdown attempts failed")
|
||||
}
|
||||
} else {
|
||||
log.Printf("Server was not started by scheduler, skipping auto shutdown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current time is within the schedule window
|
||||
// Check if we're between start and end times
|
||||
if currentTimeStr == scheduleConfig.StartTime {
|
||||
log.Printf("Schedule match: Current time exactly matches start time")
|
||||
shouldBeOn = true
|
||||
} else if currentTimeStr == scheduleConfig.EndTime {
|
||||
log.Printf("Schedule end: Current time exactly matches end time")
|
||||
shouldBeOn = false
|
||||
|
||||
// Check if auto shutdown is enabled
|
||||
if scheduleConfig.AutoShutdown && shutdownPassword != "" && isServerOnline() {
|
||||
// Only shut down if the server was started by the scheduler
|
||||
if scheduleConfig.StartedBySchedule {
|
||||
log.Printf("Auto shutdown is enabled - attempting to shut down server at end time")
|
||||
|
||||
// Try up to 3 times to shut down the server
|
||||
var shutdownSuccessful bool
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
log.Printf("Auto shutdown attempt %d/3", attempt)
|
||||
err := shutdownServer(shutdownPassword)
|
||||
if err != nil {
|
||||
log.Printf("Auto shutdown attempt %d failed: %v", attempt, err)
|
||||
if attempt < 3 {
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Auto shutdown initiated successfully on attempt %d", attempt)
|
||||
shutdownSuccessful = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !shutdownSuccessful {
|
||||
log.Printf("All auto shutdown attempts failed")
|
||||
}
|
||||
} else {
|
||||
log.Printf("Server was not started by scheduler, skipping auto shutdown")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ONLY consider the server should be on at EXACT start time or EXACT end time
|
||||
shouldBeOn = (currentTimeStr == scheduleConfig.StartTime)
|
||||
|
||||
// Log that we're waiting for exact times for actions
|
||||
if currentTimeStr != scheduleConfig.StartTime && currentTimeStr != scheduleConfig.EndTime {
|
||||
log.Printf("Not at exact schedule times - no action needed until exact start/end time")
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Schedule window check: Current=%v, Start=%v, End=%v, ShouldBeOn=%v",
|
||||
now.Format("15:04:05"), startTime.Format("15:04:05"), endTime.Format("15:04:05"), shouldBeOn)
|
||||
|
||||
// Explicitly check end time for better debugging
|
||||
if scheduleConfig.EndTime != "" && currentTimeStr == scheduleConfig.EndTime {
|
||||
log.Printf("EXACT END TIME MATCH! Current time %s equals end time - schedule window should close", currentTimeStr)
|
||||
}
|
||||
|
||||
// If we're at exact start time, update the LastRun timestamp
|
||||
if currentTimeStr == scheduleConfig.StartTime && ShouldRunToday(now) {
|
||||
// We only track that we've seen the start time
|
||||
log.Printf("Exact start time reached - marking schedule run")
|
||||
|
||||
// Don't automatically boot the server here - let the main scheduler handle it
|
||||
// We're just updating state information
|
||||
scheduleConfig.LastRun = now.Format(time.RFC3339)
|
||||
if err := saveScheduleConfig(); err != nil {
|
||||
log.Printf("Warning: Failed to save schedule config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return shouldBeOn
|
||||
}
|
||||
|
||||
// ShouldRunToday checks if the schedule should run today based on frequency
|
||||
func ShouldRunToday(now time.Time) bool {
|
||||
// We no longer check for windows - we only check at exact times
|
||||
currentTimeStr := now.Format("15:04")
|
||||
today := now.Format("2006-01-02")
|
||||
|
||||
startTime, startErr := time.Parse("2006-01-02 15:04", fmt.Sprintf("%s %s", today, scheduleConfig.StartTime))
|
||||
endTime, endErr := time.Parse("2006-01-02 15:04", fmt.Sprintf("%s %s", today, scheduleConfig.EndTime))
|
||||
|
||||
if startErr == nil && endErr == nil {
|
||||
// If end time is before start time, it means the window spans to the next day
|
||||
if endTime.Before(startTime) {
|
||||
endTime = endTime.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
// Only log that we're at an exact schedule time
|
||||
if currentTimeStr == scheduleConfig.StartTime {
|
||||
log.Println("Currently at exact start time - schedule should be active")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If no previous run, allow it to run
|
||||
if scheduleConfig.LastRun == "" {
|
||||
log.Println("No previous run recorded, schedule can run today")
|
||||
return true
|
||||
}
|
||||
|
||||
lastRun, err := time.Parse(time.RFC3339, scheduleConfig.LastRun)
|
||||
if err != nil {
|
||||
log.Printf("Error parsing last run date '%s': %v", scheduleConfig.LastRun, err)
|
||||
// If we can't parse the date, better to let it run than to block it
|
||||
return true
|
||||
}
|
||||
|
||||
// Don't allow running multiple times on the same day unless
|
||||
// it's been reset explicitly (LastRun set to empty)
|
||||
if lastRun.Year() == now.Year() && lastRun.YearDay() == now.YearDay() {
|
||||
// Check if we've passed the end time today - if so, we can reset for next run
|
||||
if scheduleConfig.EndTime != "" && currentTimeStr > scheduleConfig.EndTime {
|
||||
log.Println("Current time is after end time - resetting for next run")
|
||||
scheduleConfig.LastRun = ""
|
||||
scheduleConfig.StartedBySchedule = false // Reset this flag too
|
||||
saveScheduleConfig()
|
||||
return false
|
||||
}
|
||||
|
||||
log.Println("Schedule already ran today, skipping")
|
||||
return false
|
||||
}
|
||||
|
||||
switch scheduleConfig.Frequency {
|
||||
case "daily":
|
||||
// Run every day
|
||||
log.Println("Daily schedule: allowed to run today")
|
||||
return true
|
||||
case "every2days":
|
||||
// Check if at least 2 days have passed
|
||||
elapsed := now.Sub(lastRun)
|
||||
eligible := elapsed >= 48*time.Hour
|
||||
log.Printf("Every 2 days schedule: %v hours elapsed, eligible=%v", elapsed.Hours(), eligible)
|
||||
return eligible
|
||||
case "weekly":
|
||||
// Check if at least 7 days have passed
|
||||
elapsed := now.Sub(lastRun)
|
||||
eligible := elapsed >= 7*24*time.Hour
|
||||
log.Printf("Weekly schedule: %v days elapsed, eligible=%v", elapsed.Hours()/24, eligible)
|
||||
return eligible
|
||||
case "monthly":
|
||||
// Check if last run was in a different month
|
||||
sameMonth := lastRun.Month() == now.Month() && lastRun.Year() == now.Year()
|
||||
log.Printf("Monthly schedule: eligible=%v", !sameMonth)
|
||||
return !sameMonth
|
||||
default:
|
||||
log.Printf("Unknown frequency '%s', defaulting to daily", scheduleConfig.Frequency)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if server is online
|
||||
func isServerOnline() bool {
|
||||
var cmd *exec.Cmd
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
// macOS and Linux have slightly different ping commands
|
||||
if runtime.GOOS == "darwin" {
|
||||
|
|
@ -63,36 +432,184 @@ func isServerOnline() bool {
|
|||
cmd = exec.Command("ping", "-c", "1", "-W", "1", serverName)
|
||||
}
|
||||
|
||||
// Capture output for debugging
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
log.Printf("Checking if server %s is online...", serverName)
|
||||
err := cmd.Run()
|
||||
return err == nil
|
||||
|
||||
if err != nil {
|
||||
// Only log the full error in debug mode to avoid spamming the logs
|
||||
if stderr.String() != "" {
|
||||
log.Printf("Server %s is offline: %v - %s", serverName, err, stderr.String())
|
||||
} else {
|
||||
log.Printf("Server %s is offline", serverName)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("Server %s is online", serverName)
|
||||
return true
|
||||
}
|
||||
|
||||
// Send WOL packet
|
||||
func sendWakeOnLAN() error {
|
||||
log.Printf("Sending WOL packet to %s (%s)", serverName, macAddress)
|
||||
|
||||
// Check if wakeonlan command exists
|
||||
if _, err := exec.LookPath("wakeonlan"); err == nil {
|
||||
// Create the command
|
||||
cmd := exec.Command("wakeonlan", macAddress)
|
||||
return cmd.Run()
|
||||
|
||||
// Capture both stdout and stderr
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
// Execute the command
|
||||
err := cmd.Run()
|
||||
|
||||
// Log the result
|
||||
if err != nil {
|
||||
log.Printf("WOL command failed: %v - stderr: %s", err, stderr.String())
|
||||
return fmt.Errorf("WOL command failed: %v - %s", err, stderr.String())
|
||||
}
|
||||
|
||||
output := stdout.String()
|
||||
if output != "" {
|
||||
log.Printf("WOL command output: %s", output)
|
||||
}
|
||||
|
||||
log.Printf("WOL packet sent successfully to %s", macAddress)
|
||||
return nil
|
||||
} else {
|
||||
// wakeonlan command not found, try etherwake
|
||||
if _, err := exec.LookPath("etherwake"); err == nil {
|
||||
log.Printf("Using etherwake as wakeonlan alternative")
|
||||
cmd := exec.Command("etherwake", macAddress)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
log.Printf("etherwake command failed: %v - stderr: %s", err, stderr.String())
|
||||
return fmt.Errorf("etherwake command failed: %v - %s", err, stderr.String())
|
||||
}
|
||||
|
||||
log.Printf("WOL packet sent successfully via etherwake to %s", macAddress)
|
||||
return nil
|
||||
} else {
|
||||
// Try wol command as last resort
|
||||
if _, err := exec.LookPath("wol"); err == nil {
|
||||
log.Printf("Using wol as wakeonlan alternative")
|
||||
cmd := exec.Command("wol", macAddress)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
log.Printf("wol command failed: %v - stderr: %s", err, stderr.String())
|
||||
return fmt.Errorf("wol command failed: %v - %s", err, stderr.String())
|
||||
}
|
||||
|
||||
log.Printf("WOL packet sent successfully via wol to %s", macAddress)
|
||||
return nil
|
||||
} else {
|
||||
// Implement a fallback pure Go WOL solution
|
||||
log.Printf("No WOL tools found. Please install wakeonlan, etherwake, or wol package.")
|
||||
log.Printf("Installation instructions:")
|
||||
log.Printf(" - For Debian/Ubuntu: sudo apt-get install wakeonlan")
|
||||
log.Printf(" - For macOS: brew install wakeonlan")
|
||||
log.Printf(" - For Windows: Download from https://www.depicus.com/wake-on-lan/wake-on-lan-cmd")
|
||||
|
||||
return fmt.Errorf("wakeonlan command not found in PATH. Please install wakeonlan tool")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown server with password
|
||||
func shutdownServer(password string) error {
|
||||
log.Printf("Sending shutdown command to %s", serverName)
|
||||
|
||||
var err error
|
||||
var stderr bytes.Buffer
|
||||
|
||||
// First try using sshpass with password
|
||||
if _, err := exec.LookPath("sshpass"); err == nil {
|
||||
log.Println("Using sshpass for authentication")
|
||||
log.Printf("Password being used: %s", password)
|
||||
|
||||
// Add more SSH options to handle potential issues
|
||||
cmd := exec.Command("sshpass", "-p", password, "ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "ConnectTimeout=10",
|
||||
fmt.Sprintf("%s@%s", serverUser, serverName),
|
||||
"sudo", "-S", "shutdown", "-h", "now")
|
||||
|
||||
// Capture stderr to log any error messages
|
||||
var stderr bytes.Buffer
|
||||
stderr.Reset()
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = bytes.NewBufferString(password + "\n")
|
||||
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
log.Println("SSH command executed successfully using sshpass")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("sshpass method failed: %v - %s", err, stderr.String())
|
||||
}
|
||||
|
||||
// Try direct SSH with password via stdin
|
||||
log.Println("Trying direct SSH with password via stdin")
|
||||
log.Printf("Password being used: %s", password)
|
||||
|
||||
cmd := exec.Command("ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "ConnectTimeout=10",
|
||||
fmt.Sprintf("%s@%s", serverUser, serverName),
|
||||
"sudo", "-S", "shutdown", "-h", "now")
|
||||
|
||||
stderr.Reset()
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = bytes.NewBufferString(password + "\n")
|
||||
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
log.Println("SSH command executed successfully using direct SSH")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
log.Printf("SSH Error details: %s", stderr.String())
|
||||
|
||||
// Try a simpler shutdown command as a fallback
|
||||
log.Println("Trying simpler shutdown command as fallback")
|
||||
log.Printf("Password being used: %s", password)
|
||||
|
||||
cmd = exec.Command("ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
fmt.Sprintf("%s@%s", serverUser, serverName),
|
||||
"sudo", "shutdown", "now")
|
||||
|
||||
stderr.Reset()
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = bytes.NewBufferString(password + "\n")
|
||||
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
log.Printf("All shutdown attempts failed: %v - %s", err, stderr.String())
|
||||
return fmt.Errorf("SSH command failed: %v - %s", err, stderr.String())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue