A => .hgignore +3 -0
@@ 0,0 1,3 @@
+client-bundle/*
+/*.pyz
+/*~
No newline at end of file
A => README +30 -0
@@ 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
A => application/server.py +47 -0
@@ 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()
A => client-bundle/.keep +0 -0
A => client.py +31 -0
@@ 0,0 1,31 @@
+import os, socket, xmlrpc.client
+
+import bottle
+import waitress
+
+@bottle.route('/')
+def main():
+ return '''
+ <form action="/restart" method="post">
+ Service ID: <input name="service_id" type="text" />
+ <input value="restart" type="submit" />
+ </form>
+ '''
+
+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)
A => configuration/50-service-restarter.rules +8 -0
@@ 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;
+ }
+});
A => configuration/client.service +8 -0
@@ 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
A => configuration/client.socket +5 -0
@@ 0,0 1,5 @@
+[Socket]
+ListenStream=8080
+
+[Install]
+WantedBy=sockets.target
A => configuration/server.service +10 -0
@@ 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
A => configuration/server.socket +5 -0
@@ 0,0 1,5 @@
+[Socket]
+ListenStream=8081
+
+[Install]
+WantedBy=sockets.target
A => inventory.ini +1 -0
@@ 0,0 1,1 @@
+[examples]
A => playbook.yaml +40 -0
@@ 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"