wol-server/utils.go

661 lines
21 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"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
Status string
Color string
IsTestMode bool
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 {
// Check if templates directory exists, create if not
if _, err := os.Stat("templates"); os.IsNotExist(err) {
if err := os.Mkdir("templates", 0755); err != nil {
return fmt.Errorf("failed to create templates directory: %v", err)
}
}
// Path to the template file
templatePath := filepath.Join("templates", "status.html")
// Check if the template file exists
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
log.Printf("Template file not found at %s. Please create it.", templatePath)
return fmt.Errorf("template file not found: %s", templatePath)
}
// Parse the template from the file
var err error
tmpl, err = template.ParseFiles(templatePath)
if err != nil {
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")
// 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 {
// Otherwise check if we're within the window with a small buffer (30 seconds)
shouldBeOn = (now.After(startTime.Add(-30*time.Second)) && now.Before(endTime))
}
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 in the schedule window, update the LastRun and attempt to boot the server
if shouldBeOn {
// Only update LastRun if it hasn't been set yet for this window
if scheduleConfig.LastRun == "" {
log.Printf("Entering scheduled backup window, updating LastRun timestamp")
scheduleConfig.LastRun = now.Format(time.RFC3339)
if err := saveScheduleConfig(); err != nil {
log.Printf("Warning: Failed to save schedule config: %v", err)
}
// Force a server boot attempt on window start
if !isServerOnline() {
log.Printf("Schedule window started - attempting to boot server")
if err := sendWakeOnLAN(); err != nil {
log.Printf("Schedule boot failed: %v", err)
} else {
// Mark that server was started by scheduler
scheduleConfig.StartedBySchedule = true
if err := saveScheduleConfig(); err != nil {
log.Printf("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 {
// Special case: if we're within the current window (between start and end time),
// we should consider the schedule to be active regardless of LastRun
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)
}
// If we're currently in the window, allow it to run
if now.After(startTime) && now.Before(endTime) {
log.Println("Currently within today's schedule window - 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 tomorrow
if scheduleConfig.EndTime != "" && currentTimeStr > scheduleConfig.EndTime {
log.Println("Current time is after end time - resetting for next run")
scheduleConfig.LastRun = ""
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" {
cmd = exec.Command("ping", "-c", "1", "-W", "1000", serverName)
} else {
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()
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)
// 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)
// Create the command
cmdArgs := []string{
"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",
}
log.Printf("Full command: %s", strings.Join(cmdArgs, " "))
// 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
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)
// Create command args
cmdArgs := []string{
"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",
}
log.Printf("Full command: %s", strings.Join(cmdArgs, " "))
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
}
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)
// Create command args
cmdArgs = []string{
"ssh",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
fmt.Sprintf("%s@%s", serverUser, serverName),
"sudo", "shutdown", "now",
}
log.Printf("Full command: %s", strings.Join(cmdArgs, " "))
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())
}
return nil
}