Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions html/logs.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ <h1>Log Viewer</h1>

<button id="refreshBtn" class="save-button small-button" style="margin-left: 15px;">Refresh</button>
<button id="clearBtn" class="remove-button small-button">Clear Display</button>
<button id="rebootBtn" class="remove-button small-button" title="Reboot the Raspberry Pi">Reboot Raspberry</button>

<label for="intervalSelect" style="font-weight: bold; margin-left: 15px;">Refresh Interval:</label>
<select id="intervalSelect" style="padding: 5px; border: 1px solid #ccc; border-radius: 4px;">
Expand Down Expand Up @@ -244,6 +245,46 @@ <h1>Log Viewer</h1>
document.getElementById('logContainer').innerHTML = '<div style="color: #888;">Logs cleared.</div>';
});

document.getElementById('rebootBtn').addEventListener('click', async () => {
const firstConfirm = confirm('Are you sure you want to reboot the Raspberry Pi? The proxy will be temporarily unavailable.');
if (!firstConfirm) {
return;
}

const secondConfirm = confirm('Confirm reboot now?');
if (!secondConfirm) {
return;
}

const rebootBtn = document.getElementById('rebootBtn');
rebootBtn.disabled = true;
rebootBtn.textContent = 'Rebooting...';

try {
const response = await fetch('/api/admin/reboot', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: '{}'
});

const payload = await response.json();
if (response.ok && payload.response && payload.response.result) {
alert('Reboot initiated. The device should restart shortly.');
return;
}

const reason = payload && payload.response ? payload.response.reason : 'Unknown error';
alert(`Reboot failed: ${reason}`);
} catch (error) {
alert(`Reboot failed: ${error.message}`);
} finally {
rebootBtn.disabled = false;
rebootBtn.textContent = 'Reboot Raspberry';
}
});

document.getElementById('liveModeCheckbox').addEventListener('change', toggleLiveMode);
document.getElementById('intervalSelect').addEventListener('change', function() {
if (!isLiveMode) {
Expand Down
49 changes: 49 additions & 0 deletions internal/api/handlers/logs.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package handlers

import (
"context"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os/exec"
"strconv"
"text/template"
"time"

"github.com/wimaha/TeslaBleHttpProxy/internal/api/models"
"github.com/wimaha/TeslaBleHttpProxy/internal/logging"
)

Expand Down Expand Up @@ -76,3 +80,48 @@ func LogViewer(w http.ResponseWriter, html fs.FS) error {
template.New("html/layout.html").ParseFS(html, "html/layout.html", "html/logs.html"))
return tmpl.ExecuteTemplate(w, "layout.html", nil)
}

// RebootSystem initiates a Raspberry Pi reboot.
// The reboot is executed asynchronously so the API can return first.
func RebootSystem(w http.ResponseWriter, r *http.Request) {
var response models.Response
response.Command = "system_reboot"
response.Result = false

defer commonDefer(w, &response)

logging.Warn("System reboot requested", "remote_addr", r.RemoteAddr)

Comment on lines +86 to +94
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

POST /api/admin/reboot triggers a host reboot but currently has no access control (authn/authz) or CSRF mitigation. As-is, any party that can reach the HTTP server (or a malicious webpage via CSRF) can reboot the device. Please add a protection mechanism (e.g., require an admin token/header, restrict to localhost/private subnets, and/or validate Origin/Referer for browser-based calls) before executing the reboot.

Copilot uses AI. Check for mistakes.
go func() {
// Give the HTTP response time to flush before rebooting the host.
time.Sleep(750 * time.Millisecond)
if err := executeReboot(); err != nil {
logging.Error("Failed to execute reboot command", "error", err)
}
}()

response.Result = true
response.Reason = "System reboot initiated. The device will restart shortly."
}
Comment on lines +86 to +105
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RebootSystem sets response.Result = true unconditionally and returns success even if all reboot commands fail (failure is only logged from the goroutine). This will mislead the UI/operator when permissions or binaries are missing. Consider doing a quick preflight before responding (e.g., exec.LookPath / sudo -n checks) and returning a failure status if reboot cannot be initiated, or adjust the API semantics to explicitly indicate "request accepted" (e.g., 202) rather than "reboot succeeded".

Copilot uses AI. Check for mistakes.

func executeReboot() error {
commands := [][]string{
{"sudo", "shutdown", "-r", "now"},
{"/sbin/shutdown", "-r", "now"},
{"shutdown", "-r", "now"},
}

var lastErr error
for _, args := range commands {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
err := cmd.Run()
cancel()
if err == nil {
return nil
}
lastErr = err
Comment on lines +117 to +123
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

executeReboot discards stdout/stderr from the shutdown commands, making it difficult to diagnose common failures (e.g., sudo password required, permission denied, binary missing). Capture and log the combined output (or include it in the returned error), and consider using defer cancel() right after creating the context to ensure cleanup on all paths.

Suggested change
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
err := cmd.Run()
cancel()
if err == nil {
return nil
}
lastErr = err
defer cancel()
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
output, err := cmd.CombinedOutput()
if err == nil {
return nil
}
lastErr = fmt.Errorf("command %v failed: %w (output: %s)", args, err, string(output))

Copilot uses AI. Check for mistakes.
}

return fmt.Errorf("all reboot commands failed: %w", lastErr)
}
1 change: 1 addition & 0 deletions internal/api/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func SetupRoutes(static embed.FS, html embed.FS) *mux.Router {
router.HandleFunc("/logs", handlers.ShowLogViewer(html)).Methods("GET")
router.HandleFunc("/api/logs", handlers.GetLogs).Methods("GET")
router.HandleFunc("/api/logs/stats", handlers.GetLogStats).Methods("GET")
router.HandleFunc("/api/admin/reboot", handlers.RebootSystem).Methods("POST")
router.HandleFunc("/gen_keys", handlers.GenKeys).Methods("GET")
router.HandleFunc("/remove_keys", handlers.RemoveKeys).Methods("GET")
router.HandleFunc("/activate_key", handlers.ActivateKey).Methods("POST")
Expand Down