Удаленное управление выключением – удобно. 2 варианта:

  • через http используя python-скрипт
  • через ssh с ограничением команд (для безопасности)

Плюс, выключение по расписанию.

У меня принцип такой:

  • на “своих” системах через http
  • на коробочных системах (Proxmox, TrueNAS) через ssh, т.к. меньше вероятность, что потом при обновлении сломается

Выключение через http (python-скрипт)

/etc/systemd/system/http-poweroff.service (644):

[Unit]
Description="Allows power management from http"
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
Restart=always
ExecStart=/cloud/system/http-poweroff

[Install]
WantedBy=default.target

/usr/local/bin/http-poweroff (755):

#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
"""
Copyright 2024 Igor Stepin

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""
from http.server import BaseHTTPRequestHandler, HTTPServer
import logging
import os
import signal
import argparse
import subprocess

def process_action(action: str):
    logging.info("action: %s\n", action)
    subprocess.run(['/usr/bin/systemctl', action])
    return

class Handler(BaseHTTPRequestHandler):
    key = ''

    def write_response(self, code:int, json:str):
        self.send_response(code)
        self.send_header('Content-type', 'application/json; charset=utf-8')
        self.end_headers()
        self.wfile.write(bytes(json, "utf-8"))

    def return_200(self):
        self.write_response(200, '{"status": "success"}')

    def return_401(self):
        self.write_response(401, '{"status": "forbidden"}')

    def return_404(self):
        self.write_response(404, '{"status": "not_found"}')

    def parse_action(self):
        action = ''
        if self.path == '/poweroff':
            action = 'poweroff'
        elif self.path == '/reboot':
            action = 'reboot'
        elif self.path == '/suspend':
            action = 'suspend'
        elif self.path == '/hibernate':
            action = 'hibernate'
        elif self.path == '/hybrid-sleep':
            action = 'hybrid-sleep'
        elif self.path == '/suspend-then-hibernate':
            action = 'suspend-then-hibernate'
        return action
        
    def check_key(self):
        actual_key = self.headers.get('x-api-key', '')
        return actual_key == self.key
        
    def do_POST(self):
        logging.info("POST request,\nPath: %s\nHeaders:\n%s\n", str(self.path), str(self.headers))

        action = self.parse_action()
        if not action:
            self.return_404()
            return
        
        check_result = self.check_key()
        if not check_result:
            self.return_401()
            return

        process_action(action)
        self.return_200()
            
def run_server(ip: str, port: str, key: str, handler_class=Handler):
    logging.basicConfig(level=logging.INFO)
    logging.info(f'Starting http server on {ip}:{port} with x-api-key {key}')
    handler_class.key = key
    server = HTTPServer((ip, port), handler_class)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        logging.info('Stopping...\n')
        server.server_close()

def exit_gracefully(signum, frame):
    os.kill(os.getpid(), signal.SIGINT)

def parse_args():
    parser = argparse.ArgumentParser(
                        prog='http-poweroff',
                        description='HTTP server in Python to poweroff, reboot, suspend, hibernate, hybrid-sleep, or suspend-then-hibernate system using systemctl command.',
                        epilog='Text at the bottom of help')
    parser.add_argument('-p', '--port', type=int, default=8009, help="default is 8009")
    parser.add_argument('-b', '--bind', type=str, default="", help="provide IP address to listen on, default: all IPs of this machine")
    parser.add_argument('-k', '--key',  type=str, default="mySecre1Pa$$", help="provide secret x-api-key to poweroff this machine, default is mySecre1Pa$$")
    args = parser.parse_args()
    return args

if __name__ == '__main__':
    signal.signal(signal.SIGTERM, exit_gracefully)
    args = parse_args()
    run_server(ip=args.bind, port=args.port, key=args.key)

Выключение через ssh с ограничением команд

С точки зрения клиента:

sshpass -p 'myPassword1' ssh -o "StrictHostKeyChecking=no" poweroff@192.168.11.12 "sudo poweroff"

Естественно, заводим пользователя poweroff и разрешаем ему ограниченное sudo:

echo "poweroff ALL = NOPASSWD: /sbin/poweroff" >> /etc/sudoers

Но при этом, в принципе, он может и что-то другое сделать в системе. Как ограничивать? 2 идеи:

  • в файле /etc/passwd прописывается shell, который используется пользователем, можно его заменить на лимитированный скрипт
  • в файле ~/.ssh/authorized_keys можно прописать ограничения на запускаемые команды, но тогда нужно использовать не пароль, а ssh-ключ. Вроде бы это хорошо, но UpSnap не имеет такой опции в ui.

Начнем со второго. Принцип в том, что в файле ~/.ssh/authorized_keys перед самим ключом можно прописать ограничения.

~/.ssh/authorized_keys (лимитируем команду и запрещаем всякие продвинутые фичи типа проброса портов, т.к. мы хотим, чтобы этот пользователь только выключал компьютер):

command="sudo poweroff",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzzZ2cd6k...FVweg3 user@localhost

Но, с сожалению, непонятно как работать с ключами в UpSnap, да и не нужно это, это же просто пароль на выключение компьютера, а больше пользователь ничего не может сделать.

Поэтому первый сценарий. Делаем скрипт, который будет выключать компьютер:

/usr/local/bin/poweroff.sh (755):

#!/usr/bin/env bash
sudo poweroff

Он совершенно простой. Наша цель – одна команда без опций, которую будет использовать как shell пользователя.

Полный скрипт настройки для Proxmox (Debian 12):

#single command that allowed to the user
cat >/usr/local/bin/poweroff.sh <<EOF
#!/usr/bin/env bash
sudo poweroff
EOF
chmod 755 /usr/local/bin/poweroff.sh

# allows sudo without password
cat >/etc/sudoers.d/poweroff-user <<EOF
poweroff ALL = NOPASSWD: /sbin/poweroff
EOF

# create user and specify password
adduser --comment "Remote poweroff using ssh" --no-create-home --shell /usr/local/bin/poweroff.sh poweroff

# install sudo (it's not installed in Proxmox by default)
apt install sudo

Выключение по расписанию

Уж для комплекта.

/etc/systemd/system/poweroff.service (644):

[Unit]
Description=Poweroff job for timer

[Service]
Type=oneshot
ExecStart=systemctl poweroff

/etc/systemd/system/poweroff.timer (644):

[Unit]
Description=Poweroff daily

[Timer]
OnCalendar=*-*-* 00:10:*
Unit=poweroff.service

[Install]
WantedBy=timers.target

Для удобного включения / выключения серверов использую UpSnap.

Про включение уже была статья: Автовключение и выключение домашних серверов.