raspberrywindowsraspberrysshnginxdevops

Web Dashboard per lo Shutdown Remoto: PC Windows da Raspberry Pi

6 min di lettura
Web Dashboard per lo Shutdown Remoto: PC Windows da Raspberry Pi

In questa guida vedremo come realizzare un pannello di controllo web ospitato su un Raspberry Pi per spegnere i PC Windows con un solo tocco, con tanto di controllo di stato (Online/Offline)

1. Preparazione del PC Windows (Target)

Per ricevere il comando dal Raspberry, Windows deve avere il server SSH attivo.

Installazione OpenSSH Server

Apri PowerShell come Amministratore e lancia:

Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType 'Automatic'

Abilitazione Shutdown via SSH

Assicurati che l’utente che userai per il collegamento abbia i permessi di spegnimento. In genere un account amministratore è sufficiente.

2. Configurazione del Raspberry Pi (Server)

Sul Raspberry Pi installeremo Nginx e PHP per gestire la dashboard.

Installazione dei pacchetti necessari

sudo apt update
sudo apt install nginx php-fpm openssh-client -y

Configurazione Chiavi SSH

Per permettere al Raspberry di spegnere il PC senza inserire ogni volta la password, dobbiamo usare le chiavi SSH.

# Genera la chiave (premi invio a tutto)
ssh-keygen -t rsa

# Copia la chiave sul PC Windows
ssh-copy-id utente@indirizzo_ip_windows

3. La Web Dashboard

La dashboard interrogherà lo stato del PC e fornirà un pulsante per inviare il comando con spegnimento a 19 secondi:

shutdown /s /t 19.

4. Configurazione delle Authorized Keys su Windows

Una volta generata la chiave sul Raspberry, dobbiamo “presentarla” a Windows. Il procedimento cambia se l’utente che userai è un Amministratore o un Utente Standard.

Caso A: Utente Amministratore (Consigliato per lo Shutdown)

Se l’utente configurato nel PHP (es. administrator) ha privilegi di admin, Windows ignora la cartella dell’utente e cerca un file di sistema protetto.

Crea il file di sistema: Apri il Blocco Note come Amministratore e crea il file: C:\ProgramData\ssh\administrators_authorized_keys

Incolla la chiave: Copia la chiave pubblica del Raspberry (id_ed25519.pub) all’interno del file e salva.

Configura i permessi (Fondamentale): Affinché SSH accetti la chiave, il file deve essere leggibile solo dal sistema.

  1. Tasto destro sul file > Proprietà > Sicurezza > Avanzate.
  2. Clicca su Disabilita ereditarietà e scegli “Rimuovi tutti i permessi ereditati”.
  3. Aggiungi l’utente SYSTEM e il gruppo Administrators con permessi di “Lettura” e “Controllo completo”.
  4. Rimuovi qualsiasi altro utente o gruppo dalla lista.

Caso B: Utente Standard

Se l’utente è limitato, la procedura è più “stile Linux”:

  1. Vai in C:\Users\TuoUtente\.
  2. Crea una cartella chiamata .ssh (se non esiste).
  3. Crea all’interno un file chiamato authorized_keys (senza estensione .txt).
  4. Incolla la chiave del Raspberry.

Modifica del file sshd_config

Per essere sicuri che Windows dia la precedenza alle chiavi rispetto alla password, dobbiamo istruire il server SSH.

  1. Apri con i privilegi di admin il file C:\ProgramData\ssh\sshd_config.
  2. Assicurati che queste righe siano de-commentate (senza # davanti):
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys

Riavvia il servizio per applicare le modifiche: Apri PowerShell (Admin) e digita:

Restart-Service sshd

Il test di “Fiducia” (Host Verification)

La prima volta che il Raspberry si collega, chiederà di confermare l’identità del PC. Attenzione: se non lo fai manualmente, lo script PHP fallirà silenziosamente.

Esegui questo comando una volta sola dal terminale del Raspberry:

sudo -u www-data ssh -o StrictHostKeyChecking=accept-new tuo_utente@192.168.x.x "whoami"

Se il terminale ti restituisce il nome utente senza chiedere la password, la configurazione è perfetta!

Un piccolo consiglio : ProgramData è una cartella nascosta.

Il codice completo della dashboard (index.php)

<?php
// dati pc
$pcs = [
    "PC 1" => ["ip" => "192.168.x.x", "user" => "administrator"],
    "PC 2" => ["ip" => "192.168.x.x", "user" => "user"]
];

// controllo porta RDP (3389)
function checkRDP($ip) {
    $port = 3389;
    $connection = @fsockopen($ip, $port, $errno, $errstr, 0.5);
    if (is_resource($connection)) {
        fclose($connection);
        return true;
    }
    return false;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['pc_id'])) {
    $id = $_POST['pc_id'];

    if ($id === "all") {
        foreach ($pcs as $nome => $data) {
            $ip = $data['ip'];
            $user = $data['user'];
            $cmd = "ssh -o ConnectTimeout=2 -o StrictHostKeyChecking=no $user @$ip 'shutdown /s /t 19' > /dev/null 2>&1 &";
            shell_exec($cmd);
        }
        echo "OK";
        exit;
    }

    if (array_key_exists($id, $pcs)) {
        $ip = $pcs[$id]['ip'];
        $user = $pcs[$id]['user'];
        $cmd = "ssh -o ConnectTimeout=3 -o StrictHostKeyChecking=no $user @$ip 'shutdown /s /t 19' 2>&1";
        shell_exec($cmd);
        echo "OK";
        exit;
    }
}
?>
<!DOCTYPE html>
<html lang="it">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Power Control</title>
    <link rel="manifest" href="manifest.json">
    <meta name="theme-color" content="#ffffff">
    <meta name="mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">

    <style>
        :root {
            --noob-bg: #f8fafc;
            --noob-text: #1a202c;
            --off-red: #ed4338;
            --online-green: #48bb78;
            --offline-gray: #a0aec0;
            --card-border: #e2e8f0;
        }

        body { font-family: 'Montserrat', sans-serif; background-color: var(--noob-bg); color: var(--noob-text); margin: 0; padding: 0; }

        header {
            background-color: #ffffff; color: #000000; padding: 15px 25px;
            display: flex; justify-content: space-between; align-items: center;
            box-shadow: 0 2px 10px rgba(0,0,0,0.05); margin-bottom: 25px;
            border-bottom: 1px solid var(--card-border);
        }

        header h1 { margin: 0; font-size: 1rem; text-transform: uppercase; font-weight: 700; }
        header img { height: 35px; width: auto; filter: brightness(0); }

        .container { max-width: 500px; margin: 0 auto; padding: 0 20px; }

        .master-btn {
            width: 100%; background-color: var(--off-red); color: white; border: none;
            padding: 18px; border-radius: 12px; font-weight: 700; font-size: 0.9rem;
            cursor: pointer; margin-bottom: 25px; display: flex; align-items: center;
            justify-content: center; gap: 10px; box-shadow: 0 4px 12px rgba(237, 67, 56, 0.3);
            transition: transform 0.1s;
        }
        .master-btn:active { transform: scale(0.98); }

        .card {
            background: #ffffff; border-radius: 12px; padding: 20px; margin-bottom: 15px;
            display: flex; align-items: center; justify-content: space-between;
            border: 1px solid var(--card-border);
        }

        .info { display: flex; align-items: center; }
        .info i { font-size: 24px; margin-right: 15px; color: #4a5568; }

        .status-badge { font-size: 0.7rem; display: flex; align-items: center; gap: 5px; margin-top: 4px; font-weight: 600; }
        .dot { height: 8px; width: 8px; border-radius: 50%; display: inline-block; }

        .online { color: var(--online-green); }
        .online .dot { background-color: var(--online-green); box-shadow: 0 0 5px var(--online-green); }
        .offline { color: var(--offline-gray); }
        .offline .dot { background-color: var(--offline-gray); }

        .btn-off {
            background-color: var(--off-red); color: white; border: none;
            padding: 10px 16px; border-radius: 8px; cursor: pointer;
            font-weight: 700; font-size: 0.8rem; display: flex; align-items: center; gap: 8px;
        }

        .loading { opacity: 0.4; pointer-events: none; }
        #toast {
            position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%);
            background: #2d3748; color: white; padding: 12px 25px;
            border-radius: 8px; display: none; font-size: 0.85rem; z-index: 1000;
        }
    </style>
</head>
<script>
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('sw.js')
    .then(() => console.log("PWA: Service Worker Registrato"));
}
</script>
<body>

<header>
    <h1>Power Control</h1>
    <img src="logo-light.png" alt="Logo">
</header>

<div class="container">
    <button class="master-btn" id="btn-all" onclick="sendShutdown('all', 'all')">
        <i class="fa-solid fa-power-off"></i> SPEGNI ENTRAMBI
    </button>

    <?php foreach ($pcs as $nome => $data):
        $isOnline = checkRDP($data['ip']);
    ?>
    <div class="card" id="card-<?= md5($nome) ?>">
        <div class="info">
            <i class="fa-solid fa-desktop"></i>
            <div>
                <div class="name"><?= htmlspecialchars($nome) ?></div>
                <div class="status-badge <?= $isOnline ? 'online' : 'offline' ?>">
                    <span class="dot"></span> <?= $isOnline ? 'ONLINE (RDP)' : 'OFFLINE' ?>
                </div>
            </div>
        </div>
        <button class="btn-off" <?= !$isOnline ? 'disabled style="background:#cbd5e0; cursor:not-allowed;"' : '' ?> onclick="sendShutdown('<?= $nome ?>', '<?= md5($nome) ?>')">
            <i class="fa-solid fa-power-off"></i> SPEGNI
        </button>
    </div>
    <?php endforeach; ?>
</div>

<div id="toast">Segnale inviato...</div>

<script>
function sendShutdown(pcId, cardUid) {
    const isMaster = (pcId === 'all');
    const target = isMaster ? document.getElementById('btn-all') : document.getElementById('card-' + cardUid);

    target.classList.add('loading');

    const params = new URLSearchParams();
    params.append('pc_id', pcId);

    fetch('', { method: 'POST', body: params })
    .then(() => {
        const toast = document.getElementById('toast');
        toast.style.display = 'block';
        setTimeout(() => {
            location.reload();
        }, 3000);
    });
}
</script>
</body>
</html>