# HG changeset patch # User Nolan Prescott # Date 1688870411 14400 # Sat Jul 08 22:40:11 2023 -0400 # Node ID aee4f5c67c3d52f89935013f8c89462ecfd9526b # Parent 0000000000000000000000000000000000000000 Initial commit Here's a demo of a silly idea for separating a web application from another service that controls systemd service restarts and then links them together. Is this a terrible idea? Maybe! diff --git a/.hgignore b/.hgignore new file mode 100644 --- /dev/null +++ b/.hgignore @@ -0,0 +1,3 @@ +client-bundle/* +/*.pyz +/*~ \ No newline at end of file diff --git a/README b/README new file mode 100644 --- /dev/null +++ b/README @@ -0,0 +1,30 @@ +A goofy idea for a web application + +Fuller write up here: + +https://idle.nprescott.com/2023/spooky-web-application-idea.html + +It is a minimal web application communicating requests to restart +systemd services via XML-RPC. + +The default configuration assumes the RPC server is located at +10.0.0.2 for no real reason other than convenience. + +I've been using the ansible playbook like this (noted for my own sake, +I won't remember this in the future): + + ansible-playbook --key-file ~/.ssh/example-key \ + -i inventory.ini \ + -u root playbook.yaml + +Obviously inventory.ini needs to be populated with the IP addresses in +use! + +The referenced `client.pyz` is a Python zipapp that is created from +the `client.py` file and its dependencies: + + python -m pip install --target client-bundle waitress bottle + + cp client.py client-bundle/__main__.py + + python -m zipapp -o application/client.pyz client-bundle diff --git a/application/server.py b/application/server.py new file mode 100644 --- /dev/null +++ b/application/server.py @@ -0,0 +1,47 @@ +import dataclasses, os, re, socket, socketserver, xmlrpc.server + +import dbus + + +class ThreadedSimpleXMLRPCServer(socketserver.ThreadingMixIn, + xmlrpc.server.SimpleXMLRPCServer): + """A threaded, socket-activated SimpleXMLRPCServer""" + def __init__(self): + xmlrpc.server.SimpleXMLRPCServer.__init__(self, (None, None), bind_and_activate=False) + SYSTEMD_FIRST_SOCKET_FD = 3 + self.socket = socket.fromfd(SYSTEMD_FIRST_SOCKET_FD, socket.AF_INET, socket.SOCK_STREAM) + + +def valid_id(ident): + # a more robust validation would check existence instead of format + return bool(re.match(r'^[0-9]{4}$', str(ident))) + + +@dataclasses.dataclass +class Result: + success: bool + message: str + + +class Service: + def restart_unit(self, identifier): + if not valid_id(identifier): + print(f'invalid identifer: {identifier}') + return Result(success=False, message='invalid identifier') + try: + sysbus = dbus.SystemBus() + systemd1 = sysbus.get_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1") + manager = dbus.Interface(systemd1, "org.freedesktop.systemd1.Manager") + job = manager.RestartUnit(f"sandbox@{identifier}.service", "replace") + return Result(success=True, message='success') + except Exception as e: + print(f'Exception occurred: {e}') + return Result(success=False, message=f'{e}') + + +if __name__ == '__main__': + # TODO this doesn't error out with a bad FD when not + # socket-activated, should fix that + with ThreadedSimpleXMLRPCServer() as server: + server.register_instance(Service()) + server.serve_forever() diff --git a/client-bundle/.keep b/client-bundle/.keep new file mode 100644 diff --git a/client.py b/client.py new file mode 100644 --- /dev/null +++ b/client.py @@ -0,0 +1,31 @@ +import os, socket, xmlrpc.client + +import bottle +import waitress + +@bottle.route('/') +def main(): + return ''' +
+ Service ID: + +
+ ''' + +def invoke_restart(identifier, rpc_server_address): + proxy = xmlrpc.client.ServerProxy(rpc_server_address) + return proxy.restart_unit(identifier) + +@bottle.post('/restart') +def restart_handler(): + service_id = bottle.request.forms.get('service_id') + return invoke_restart(service_id, bottle.request.app.config['RPC_SERVER']) + +if __name__ == '__main__': + SYSTEMD_FIRST_SOCKET_FD = 3 + sockets = [socket.fromfd(SYSTEMD_FIRST_SOCKET_FD, socket.AF_INET, socket.SOCK_STREAM)] + application = bottle.default_app() + rpc_server = os.getenv('RPC_SERVER') + print(f'RPC server is: {rpc_server}') + application.config['RPC_SERVER'] = rpc_server + waitress.serve(application, sockets=sockets) diff --git a/configuration/50-service-restarter.rules b/configuration/50-service-restarter.rules new file mode 100644 --- /dev/null +++ b/configuration/50-service-restarter.rules @@ -0,0 +1,8 @@ +polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.systemd1.manage-units" && + /^sandbox@(\d{4})\.service$/.test(action.lookup("unit")) && + action.lookup("verb") == "restart" && + subject.user == "restarter") { + return polkit.Result.YES; + } +}); diff --git a/configuration/client.service b/configuration/client.service new file mode 100644 --- /dev/null +++ b/configuration/client.service @@ -0,0 +1,8 @@ +[Unit] +Description=web app / xml-rpc client +Requires=client.socket + +[Service] +DynamicUser=true +Environment="RPC_SERVER=http://10.0.0.2:8081" +ExecStart=/usr/bin/python /opt/client.pyz diff --git a/configuration/client.socket b/configuration/client.socket new file mode 100644 --- /dev/null +++ b/configuration/client.socket @@ -0,0 +1,5 @@ +[Socket] +ListenStream=8080 + +[Install] +WantedBy=sockets.target diff --git a/configuration/server.service b/configuration/server.service new file mode 100644 --- /dev/null +++ b/configuration/server.service @@ -0,0 +1,10 @@ +[Unit] +Description=xml-rpc server and service restarter +Requires=server.socket + +[Service] +User=restarter +DynamicUser=true +ProtectHome=true +PrivateUsers=true +ExecStart=/usr/bin/python /opt/server.py diff --git a/configuration/server.socket b/configuration/server.socket new file mode 100644 --- /dev/null +++ b/configuration/server.socket @@ -0,0 +1,5 @@ +[Socket] +ListenStream=8081 + +[Install] +WantedBy=sockets.target diff --git a/inventory.ini b/inventory.ini new file mode 100644 --- /dev/null +++ b/inventory.ini @@ -0,0 +1,1 @@ +[examples] diff --git a/playbook.yaml b/playbook.yaml new file mode 100644 --- /dev/null +++ b/playbook.yaml @@ -0,0 +1,40 @@ +- name: demo XML-RPC silliness + hosts: examples + tasks: + + - name: copy web app (xmlrpc client) application + ansible.builtin.copy: + src: application/client.pyz + dest: /opt/client.pyz + mode: '0755' + + - name: copy server application + ansible.builtin.copy: + src: application/server.py + dest: /opt/server.py + mode: '0755' + + - name: copy service/socket files + ansible.builtin.copy: + src: "configuration/{{ item }}" + dest: "/etc/systemd/system/{{ item }}" + with_items: + - "client.socket" + - "client.service" + - "server.socket" + - "server.service" + + - name: copy polkit rules + ansible.builtin.copy: + src: configuration/50-service-restarter.rules + dest: /etc/polkit-1/rules.d/50-service-restarter.rules + + - name: daemon reload, restart services + ansible.builtin.systemd: + name: "{{ item }}" + state: restarted + daemon-reload: true + with_items: + - "polkit.service" + - "server.socket" + - "client.socket"