diff --git a/.env b/.env deleted file mode 100644 index 6641729..0000000 --- a/.env +++ /dev/null @@ -1,4 +0,0 @@ -SERVER_NAME=delemaco -SERVER_USER=root -MAC_ADDRESS=b8:cb:29:a1:f3:88 -PORT=8080 diff --git a/.env.sample b/.env.sample index ea2a15a..39aae63 100644 --- a/.env.sample +++ b/.env.sample @@ -2,3 +2,4 @@ SERVER_NAME=pippo SERVER_USER=root MAC_ADDRESS=aa:aa:aa:aa:aa:aa PORT=8080 +SHUTDOWN_PASSWORD="password" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b4a8249..3dd69d0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,205 +2,137 @@ name: Build and Release on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] 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 + - name: Checkout code + uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.20' + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" - - name: Build for ARM (Pi Zero, Pi 1 - ARMv6) - run: | - GOOS=linux GOARCH=arm GOARM=6 go build -ldflags="-s -w" -o wol-server-arm6 + - name: Build for ARMv6 (Pi Zero / Pi 1) + run: | + GOOS=linux GOARCH=arm GOARM=6 \ + go build -ldflags="-s -w" -o wol-server-armv6 - - name: Create systemd service file - run: | - cat > wol-server.service << 'EOL' - [Unit] - Description=WOL Server Go Application - After=network.target + - 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 - [Service] - User=pi - WorkingDirectory=/home/pi/wol-server - ExecStart=/home/pi/wol-server/wol-server - Restart=always + - name: Create systemd service file + run: | + cat > wol-server.service << 'EOF' + [Unit] + Description=WOL Server Go Application + After=network.target - [Install] - WantedBy=multi-user.target - EOL + [Service] + Type=simple + User=loke + WorkingDirectory=/home/loke/wol-server + ExecStart=/home/loke/wol-server/wol-server + Restart=always + RestartSec=3 - - name: Create deployment script - run: | - cat > install.sh << 'EOL' - #!/bin/bash - set -e + [Install] + WantedBy=multi-user.target + EOF - # Installation directory - INSTALL_DIR=~/wol-server + - name: Create install script + run: | + cat > install.sh << 'EOF' + #!/bin/bash + set -e - echo "Creating installation directory..." - mkdir -p $INSTALL_DIR - mkdir -p $INSTALL_DIR/templates + INSTALL_DIR="$HOME/wol-server" - echo "Installing application..." - cp wol-server-arm6 $INSTALL_DIR/wol-server - chmod +x $INSTALL_DIR/wol-server + echo "Creating installation directory..." + mkdir -p "$INSTALL_DIR/templates" - echo "Installing template files..." - cp -r templates/* $INSTALL_DIR/templates/ + 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 system service..." - sudo cp wol-server.service /etc/systemd/system/ - sudo systemctl daemon-reload - sudo systemctl enable wol-server + echo "Detected architecture: $ARCH" + echo "Using binary: $BIN" - # Install required dependencies - echo "Installing dependencies..." - sudo apt-get update -qq - sudo apt-get install -y wakeonlan sshpass + cp "$BIN" "$INSTALL_DIR/wol-server" + chmod +x "$INSTALL_DIR/wol-server" - # Start the service - echo "Starting service..." - sudo systemctl restart wol-server + echo "Installing templates..." + cp -r templates/* "$INSTALL_DIR/templates/" - echo "===========================================" - echo "Installation complete!" - echo "The WOL server is now running at http://$(hostname -I | awk '{print $1}'):8080" - echo "===========================================" - EOL + echo "Installing systemd service..." + sudo cp wol-server.service /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable wol-server - chmod +x install.sh + echo "Installing dependencies..." + sudo apt-get update -qq + sudo apt-get install -y wakeonlan sshpass - - name: Create README with installation instructions - run: | - cat > INSTALL.md << 'EOL' - # WOL Server Installation Guide + echo "Starting service..." + sudo systemctl restart wol-server - This guide will help you install the Wake-on-LAN server on your Raspberry Pi. + echo "===========================================" + echo "WOL Server installed successfully!" + echo "URL: http://$(hostname -I | awk '{print $1}'):8080" + echo "===========================================" + EOF - ## Prerequisites + chmod +x install.sh - - Raspberry Pi running Raspberry Pi OS (Raspbian) - - SSH access to your Pi - - SCP or SFTP capability to transfer files + - name: Create release package + run: | + mkdir -p package + cp wol-server-armv6 package/ + cp wol-server-arm64 package/ + cp wol-server.service package/ + cp install.sh package/ + cp -r templates package/ - ## Installation Steps + tar -czf wol-server.tar.gz -C package . - ### 1. Transfer Files to Raspberry Pi + - name: Create Forgejo Release + if: github.ref == 'refs/heads/main' + run: | + TAG="v${{ github.run_number }}" + API_URL="${{ github.server_url }}/api/v1" + REPO="${{ github.repository }}" - **Option 1: Using SCP from your computer** + # 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) - ```bash - # Replace with your Pi's IP address - PI_IP=192.168.1.100 + # 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 - # 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.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 - 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 }} + echo "Release ${TAG} created with asset wol-server.tar.gz" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/README.md b/README.md index 9b602d8..0e16060 100644 --- a/README.md +++ b/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 @@ -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 diff --git a/handlers.go b/handlers.go index 0c4333e..684c558 100644 --- a/handlers.go +++ b/handlers.go @@ -4,6 +4,7 @@ import ( "log" "net/http" "runtime" + "time" ) // Handle the root route - show status @@ -13,21 +14,32 @@ 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 + color := "#4caf50" // Material green if !online { status = "Offline" - color = "#d32f2f" // Material red + color = "#d32f2f" // Material red } + // Get current schedule configuration + scheduleConfig := GetScheduleConfig() + data := StatusData{ - Server: serverName, - Status: status, - Color: color, - IsTestMode: runtime.GOOS == "darwin", - AskPassword: false, - ErrorMessage: "", + Server: serverName, + Status: status, + Color: color, + 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 { @@ -47,11 +59,14 @@ func bootHandler(w http.ResponseWriter, r *http.Request) { // Display booting status data := StatusData{ - Server: serverName, - Status: "Booting", - Color: "#607d8b", // Material blue-gray - IsTestMode: runtime.GOOS == "darwin", - AskPassword: false, + Server: serverName, + Status: "Booting", + 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) @@ -60,11 +75,14 @@ func bootHandler(w http.ResponseWriter, r *http.Request) { } else { // Server is already online data := StatusData{ - Server: serverName, - Status: "Online", - Color: "#4caf50", // Material green - IsTestMode: runtime.GOOS == "darwin", - AskPassword: false, + Server: serverName, + Status: "Online", + 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) @@ -80,11 +98,14 @@ func confirmShutdownHandler(w http.ResponseWriter, r *http.Request) { if !online { // Server is already offline data := StatusData{ - Server: serverName, - Status: "Offline", - Color: "#d32f2f", // Material red - IsTestMode: runtime.GOOS == "darwin", - AskPassword: false, + Server: serverName, + Status: "Offline", + 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,43 +114,54 @@ func confirmShutdownHandler(w http.ResponseWriter, r *http.Request) { return } - // Show confirmation dialog - data := StatusData{ - Server: serverName, - Status: "Online", - Color: "#4caf50", // Material green - IsTestMode: runtime.GOOS == "darwin", - ConfirmShutdown: true, - AskPassword: false, - } - 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) + // 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 password entry dialog + // Show confirmation dialog - we'll use the password from .env data := StatusData{ - Server: serverName, - Status: "Online", - Color: "#4caf50", // Material green - IsTestMode: runtime.GOOS == "darwin", - AskPassword: true, + Server: serverName, + Status: "Online", + Color: "#4caf50", // Material green + IsTestMode: runtime.GOOS == "darwin", + ConfirmShutdown: true, + 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 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) { // Only process POST requests for security @@ -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", + Server: serverName, + Status: "Online", + Color: "#4caf50", + IsTestMode: runtime.GOOS == "darwin", + 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.", + Server: serverName, + Status: "Online", + Color: "#4caf50", + IsTestMode: runtime.GOOS == "darwin", + 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) @@ -189,11 +219,14 @@ func shutdownHandler(w http.ResponseWriter, r *http.Request) { // Display shutting down status data := StatusData{ - Server: serverName, - Status: "Shutting down", - Color: "#5d4037", // Material brown - IsTestMode: runtime.GOOS == "darwin", - AskPassword: false, + Server: serverName, + Status: "Shutting down", + 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) @@ -202,11 +235,14 @@ func shutdownHandler(w http.ResponseWriter, r *http.Request) { } else { // Server is already offline data := StatusData{ - Server: serverName, - Status: "Offline", - Color: "#d32f2f", // Material red - IsTestMode: runtime.GOOS == "darwin", - AskPassword: false, + Server: serverName, + Status: "Offline", + 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) diff --git a/main.go b/main.go index 7bb50e8..d4b78c6 100644 --- a/main.go +++ b/main.go @@ -1,21 +1,27 @@ package main import ( + "bytes" + "encoding/json" "fmt" "log" "net/http" "os" + "os/exec" "runtime" + "strconv" + "time" "github.com/joho/godotenv" ) // Default values var ( - serverName = "server" // Server to ping - serverUser = "root" // SSH username - macAddress = "aa:aa:aa:aa:aa:aa" // MAC address of the server - port = "8080" // Port to listen on + serverName = "server" // Server to ping + 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 + } +} diff --git a/schedule.json b/schedule.json new file mode 100644 index 0000000..a07df7d --- /dev/null +++ b/schedule.json @@ -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 +} \ No newline at end of file diff --git a/templates/status.html b/templates/status.html index 2c00eaa..1aae2aa 100644 --- a/templates/status.html +++ b/templates/status.html @@ -1,402 +1,773 @@ - + -
- - + + + ++ Start Time: + {{.Schedule.StartTime}} +
++ End Time: + {{.Schedule.EndTime}} +
++ Frequency: + {{.Schedule.Frequency}} +
++ Auto Shutdown: + {{if .Schedule.AutoShutdown}} + Enabled + {{else}} + Disabled + {{end}} +
++ Last Update: + {{.LastUpdated}} +
+