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!
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"