Remote shutdown control is convenient. 2 options:

  • via http using a python script
  • via ssh with limited commands (for security)

Plus, the shutdown is scheduled.

My principle is this:

  • on “their” systems via http
  • on boxed systems (Proxmox, TrueNAS) via ssh, because it is less likely that it will break later during the update

Shutdown via http (python script)

/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)

Shutdown via ssh with limited commands

From the client’s point of view:

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

Naturally, we get a poweroff user and allow him a limited sudo:

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

But at the same time, in principle, he can do something else in the system. How to limit it? 2 ideas:

  • the shell is written in the file /etc/passwd, which is used by the user, you can replace it with a limited script
  • in the file ~/.ssh/authorized_keys, you can specify restrictions on the commands to be run, but then you need to use an ssh key instead of a password. It seems to be good, but UpSnap does not have such an option in the ui.

Let’s start with the second one. The principle is that in the file ~/.ssh/authorized_keys you can specify restrictions before the key itself.

~/.ssh/authorized_keys (we limit the command and prohibit all kinds of advanced features such as port forwarding, because we only want this user to turn off the computer):

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

But, unfortunately, it is not clear how to work with keys in UpSnap, and you don’t need it, it’s just a password to turn off the computer, and the user can’t do anything else.

Therefore, the first scenario. We are making a script that will turn off the computer:

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

#!/usr/bin/env bash
sudo poweroff

It’s completely simple. Our goal is a single command without options that will be used as a user shell.

Full configuration script for 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

Scheduled shutdown

It’s for the kit.

/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

For convenient switching on/off of servers, I use UpSnap.

There was already an article about switching on: Auto-on and off of home servers.