Repository: cobbler/cobbler

Issue:
Enable Cobbler to be proxied behind a wider range of webservers
### Is your feature request related to a problem?

During #3197 it came to my attention that NGINX is not suitable for `mod_wsgi`.

### Provide a detailed description of the proposed feature

Gunicorn is an implementation of the WSGI standard (PEP 3333). Let's use it to take our existing code and serve it with it.

### Alternatives you've considered

- Other UWSGI servers are existing but they require a script in the webroot (if I researched correctly). It may be favourable that this script is not in the webroot.
- Staying with `mod_wsgi`: Not feasible as this locks us in with apache2 and that might not be an options in rare cases (also there are proxies and servers with far better performance).

### Additional information

Discussion about this can be found here: https://github.com/cobbler/cobbler/pull/3197#discussion_r938833494



Discussion:

Resolving patch:
diff --git a/cobbler/services.py b/cobbler/services.py
--- a/cobbler/services.py
+++ b/cobbler/services.py
@@ -8,6 +8,7 @@
 
 import json
 import time
+import urllib
 import xmlrpc.client
 import yaml
 
@@ -75,7 +76,7 @@ def autoinstall(
         data = self.remote.generate_autoinstall(
             profile, system, REMOTE_ADDR, REMOTE_MAC
         )
-        return "%s" % data
+        return f"{data}"
 
     def ks(self, profile=None, system=None, REMOTE_ADDR=None, REMOTE_MAC=None, **rest):
         """
@@ -93,7 +94,7 @@ def ks(self, profile=None, system=None, REMOTE_ADDR=None, REMOTE_MAC=None, **res
         data = self.remote.generate_autoinstall(
             profile, system, REMOTE_ADDR, REMOTE_MAC
         )
-        return "%s" % data
+        return f"{data}"
 
     def ipxe(self, profile=None, image=None, system=None, mac=None, **rest):
         """
@@ -117,7 +118,7 @@ def ipxe(self, profile=None, image=None, system=None, mac=None, **rest):
                 system = found[0]
 
         data = self.remote.generate_ipxe(profile, image, system)
-        return "%s" % data
+        return f"{data}"
 
     def bootcfg(self, profile=None, system=None, **rest):
         """
@@ -130,7 +131,7 @@ def bootcfg(self, profile=None, system=None, **rest):
         """
         self.__xmlrpc_setup()
         data = self.remote.generate_bootcfg(profile, system)
-        return "%s" % data
+        return f"{data}"
 
     def script(self, profile=None, system=None, **rest) -> str:
         """
@@ -147,7 +148,7 @@ def script(self, profile=None, system=None, **rest) -> str:
         data = self.remote.generate_script(
             profile, system, rest["query_string"]["script"][0]
         )
-        return "%s" % data
+        return f"{data}"
 
     def events(self, user="", **rest) -> str:
         """
@@ -259,7 +260,6 @@ def list(self, what="systems", **rest) -> str:
         :return: The list of object names.
         """
         self.__xmlrpc_setup()
-        buf = ""
         if what == "systems":
             listing = self.remote.get_systems()
         elif what == "profiles":
@@ -280,9 +280,10 @@ def list(self, what="systems", **rest) -> str:
             listing = self.remote.get_menus()
         else:
             return "?"
-        for x in listing:
-            buf += "%s\n" % x["name"]
-        return buf
+        names = [x["name"] for x in listing]
+        if len(names) > 0:
+            return "\n".join(names) + "\n"
+        return ""
 
     def autodetect(self, **rest) -> str:
         """
@@ -316,7 +317,7 @@ def autodetect(self, **rest) -> str:
                         candidates.append(x)
 
         if len(candidates) == 0:
-            return "FAILED: no match (%s,%s)" % (ip, macinput)
+            return f"FAILED: no match ({ip},{macinput})"
         elif len(candidates) > 1:
             return "FAILED: multiple matches"
         elif len(candidates) == 1:
@@ -336,19 +337,19 @@ def find_autoinstall(self, system=None, profile=None, **rest):
 
         name = "?"
         if system is not None:
-            url = "%s/cblr/svc/op/autoinstall/system/%s" % (self.server, name)
+            url = f"{self.server}/cblr/svc/op/autoinstall/system/{name}"
         elif profile is not None:
-            url = "%s/cblr/svc/op/autoinstall/profile/%s" % (self.server, name)
+            url = f"{self.server}/cblr/svc/op/autoinstall/profile/{name}"
         else:
             name = self.autodetect(**rest)
             if name.startswith("FAILED"):
-                return "# autodetection %s" % name
-            url = "%s/cblr/svc/op/autoinstall/system/%s" % (self.server, name)
+                return f"# autodetection {name}"
+            url = f"{self.server}/cblr/svc/op/autoinstall/system/{name}"
 
         try:
             return self.dlmgr.urlread(url)
         except:
-            return "# automatic installation file retrieval failed (%s)" % url
+            return f"# automatic installation file retrieval failed ({url})"
 
     def findks(self, system=None, profile=None, **rest):
         """
@@ -364,19 +365,19 @@ def findks(self, system=None, profile=None, **rest):
 
         name = "?"
         if system is not None:
-            url = "%s/cblr/svc/op/ks/system/%s" % (self.server, name)
+            url = f"{self.server}/cblr/svc/op/ks/system/{name}"
         elif profile is not None:
-            url = "%s/cblr/svc/op/ks/profile/%s" % (self.server, name)
+            url = f"{self.server}/cblr/svc/op/ks/profile/{name}"
         else:
             name = self.autodetect(**rest)
             if name.startswith("FAILED"):
-                return "# autodetection %s" % name
-            url = "%s/cblr/svc/op/ks/system/%s" % (self.server, name)
+                return f"# autodetection {name}"
+            url = f"{self.server}/cblr/svc/op/ks/system/{name}"
 
         try:
             return self.dlmgr.urlread(url)
         except:
-            return "# kickstart retrieval failed (%s)" % url
+            return f"# kickstart retrieval failed ({url})"
 
     def puppet(self, hostname=None, **rest) -> str:
         """
@@ -434,3 +435,103 @@ def puppet(self, hostname=None, **rest) -> str:
             classes = list(classes.keys())
 
         return yaml.dump(data, default_flow_style=False)
+
+
+def __fillup_form_dict(form: dict, my_uri: str) -> str:
+    """
+    Helper function to fillup the form dict with required mode information.
+
+    :param form: The form dict to manipulate
+    :param my_uri: The URI to work with.
+    :return: The normalized URI.
+    """
+    my_uri = urllib.parse.unquote(my_uri)
+
+    tokens = my_uri.split("/")
+    tokens = tokens[1:]
+    label = True
+    field = ""
+    for token in tokens:
+        if label:
+            field = token
+        else:
+            form[field] = token
+        label = not label
+    return my_uri
+
+
+def __generate_remote_mac_list(environ: dict) -> list:
+    # This MAC header is set by anaconda during a kickstart booted with the
+    # kssendmac kernel option. The field will appear here as something
+    # like: eth0 XX:XX:XX:XX:XX:XX
+    mac_counter = 0
+    remote_macs = []
+    mac_header = f"HTTP_X_RHN_PROVISIONING_MAC_{mac_counter:d}"
+    while environ.get(mac_header, None):
+        remote_macs.append(environ[mac_header])
+        mac_counter = mac_counter + 1
+        mac_header = f"HTTP_X_RHN_PROVISIONING_MAC_{mac_counter:d}"
+    return remote_macs
+
+
+def application(environ, start_response):
+    """
+    UWSGI entrypoint for Gunicorn
+
+    :param environ:
+    :param start_response:
+    :return:
+    """
+
+    form = {}
+    my_uri = __fillup_form_dict(form, environ["RAW_URI"])
+    form["query_string"] = urllib.parse.parse_qs(environ["QUERY_STRING"])
+    form["REMOTE_MACS"] = __generate_remote_mac_list(environ)
+
+    # REMOTE_ADDR isn't a required wsgi attribute so it may be naive to assume it's always present in this context.
+    form["REMOTE_ADDR"] = environ.get("REMOTE_ADDR", None)
+
+    # Read config for the XMLRPC port to connect to:
+    with open("/etc/cobbler/settings.yaml") as main_settingsfile:
+        ydata = yaml.safe_load(main_settingsfile)
+
+    # Instantiate a CobblerWeb object
+    http_api = CobblerSvc(server=f'http://127.0.0.1:{ydata.get("xmlrpc_port", 25151)}')
+
+    # Check for a valid path/mode; handle invalid paths gracefully
+    mode = form.get("op", "index")
+
+    # TODO: We could do proper exception handling here and return
+    # Corresponding HTTP status codes:
+
+    status = "200 OK"
+    # Execute corresponding operation on the CobblerSvc object:
+    func = getattr(http_api, mode)
+    try:
+        content = func(**form)
+
+        if content.find("# *** ERROR ***") != -1:
+            status = "500 SERVER ERROR"
+            print("possible cheetah template error")
+
+        # TODO: Not sure these strings are the right ones to look for...
+        elif (
+            content.find("# profile not found") != -1
+            or content.find("# system not found") != -1
+            or content.find("# object not found") != -1
+        ):
+            print(f"content not found: {my_uri}")
+            status = "404 NOT FOUND"
+    except xmlrpc.server.Fault as err:
+        status = "500 SERVER ERROR"
+        content = err.faultString
+
+    content = content.encode("utf-8")
+
+    response_headers = [
+        ("Content-type", "text/plain;charset=utf-8"),
+        ("Content-Length", str(len(content))),
+    ]
+    start_response(status, response_headers)
+
+    return [content]
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -538,7 +538,6 @@ def run(self):
         ],
         keywords=["pxe", "autoinstallation", "dhcp", "tftp", "provisioning"],
         install_requires=[
-            "mod_wsgi",
             "requests",
             "pyyaml",
             "netaddr",
@@ -576,6 +575,7 @@ def run(self):
         },
         configure_files=[
             "config/apache/cobbler.conf",
+            "config/nginx/cobbler.conf",
             "config/cobbler/settings.yaml",
             "config/service/cobblerd.service",
             "templates/etc/named.template",
@@ -596,10 +596,11 @@ def run(self):
             ("%s/loaders" % libpath, []),
             ("%s/misc" % libpath, glob("misc/*")),
             # Configuration
+            (f"{etcpath}/apache", ["build/config/apache/cobbler.conf"]),
+            (f"{etcpath}/nginx", ["build/config/nginx/cobbler.conf"]),
             (
                 "%s" % etcpath,
                 [
-                    "build/config/apache/cobbler.conf",
                     "build/config/service/cobblerd.service",
                     "build/config/cobbler/settings.yaml",
                 ],
@@ -650,8 +651,6 @@ def run(self):
             ("%s/httpd/cobbler" % logpath, []),
             ("%s/cobbler/anamon" % logpath, []),
             ("%s/cobbler/tasks" % logpath, []),
-            # A script that isn't really data, wsgi script
-            ("%s/cobbler/svc/" % webroot, ["svc/services.py"]),
             # zone-specific templates directory
             ("%s/zone_templates" % etcpath, glob("templates/zone_templates/*")),
             # windows-specific templates directory
diff --git a/svc/services.py b/svc/services.py
deleted file mode 100644
--- a/svc/services.py
+++ /dev/null
@@ -1,133 +0,0 @@
-"""
-This module is a mod_wsgi application used to serve up the Cobbler
-service URLs.
-
-Copyright 2010, Red Hat, Inc and Others
-
-This program is free software; you can redistribute it and/or modify
-it under the terms of the GNU General Public License as published by
-the Free Software Foundation; either version 2 of the License, or
-(at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program; if not, write to the Free Software
-Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-02110-1301  USA
-"""
-# Only add standard python modules here. When running under a virtualenv other modules are not
-# available at this point.
-
-from builtins import str
-import os
-import urllib.request
-import urllib.parse
-import urllib.error
-import xmlrpc.server
-
-import yaml
-
-
-def application(environ, start_response):
-
-    if "VIRTUALENV" in environ and environ["VIRTUALENV"] != "":
-        # VIRTUALENV Support
-        # see https://code.google.com/p/modwsgi/wiki/VirtualEnvironments
-        import site
-        import distutils.sysconfig
-
-        site.addsitedir(
-            distutils.sysconfig.get_python_lib(prefix=environ["VIRTUALENV"])
-        )
-        # Now all modules are available even under a virtualenv
-
-    from cobbler.services import CobblerSvc
-
-    my_uri = urllib.parse.unquote(environ["REQUEST_URI"])
-
-    form = {}
-
-    # canonicalizes uri, mod_python does this, mod_wsgi does not
-    my_uri = os.path.realpath(my_uri)
-
-    tokens = my_uri.split("/")
-    tokens = tokens[3:]
-    label = True
-    field = ""
-    for t in tokens:
-        if label:
-            field = t
-        else:
-            form[field] = t
-        label = not label
-
-    form["query_string"] = urllib.parse.parse_qs(environ["QUERY_STRING"])
-
-    # This MAC header is set by anaconda during a kickstart booted with the
-    # kssendmac kernel option. The field will appear here as something
-    # like: eth0 XX:XX:XX:XX:XX:XX
-    mac_counter = 0
-    remote_macs = []
-    mac_header = "HTTP_X_RHN_PROVISIONING_MAC_%d" % mac_counter
-    while environ.get(mac_header, None):
-        remote_macs.append(environ[mac_header])
-        mac_counter = mac_counter + 1
-        mac_header = "HTTP_X_RHN_PROVISIONING_MAC_%d" % mac_counter
-
-    form["REMOTE_MACS"] = remote_macs
-
-    # REMOTE_ADDR isn't a required wsgi attribute so it may be naive to assume
-    # it's always present in this context.
-    form["REMOTE_ADDR"] = environ.get("REMOTE_ADDR", None)
-
-    # Read config for the XMLRPC port to connect to:
-    with open("/etc/cobbler/settings.yaml") as main_settingsfile:
-        ydata = yaml.safe_load(main_settingsfile)
-    remote_port = ydata.get("xmlrpc_port", 25151)
-
-    # instantiate a CobblerWeb object
-    cw = CobblerSvc(server="http://127.0.0.1:%s" % remote_port)
-
-    # check for a valid path/mode
-    # handle invalid paths gracefully
-    mode = form.get("op", "index")
-
-    # TODO: We could do proper exception handling here and return
-    # corresponding HTTP status codes:
-
-    status = "200 OK"
-    # Execute corresponding operation on the CobblerSvc object:
-    func = getattr(cw, mode)
-    try:
-        content = func(**form)
-
-        if content.find("# *** ERROR ***") != -1:
-            status = "500 SERVER ERROR"
-            print("possible cheetah template error")
-
-        # TODO: Not sure these strings are the right ones to look for...
-        elif (
-            content.find("# profile not found") != -1
-            or content.find("# system not found") != -1
-            or content.find("# object not found") != -1
-        ):
-            print(("content not found: %s" % my_uri))
-            status = "404 NOT FOUND"
-    except xmlrpc.server.Fault as err:
-        status = "500 SERVER ERROR"
-        content = err.faultString
-
-    content = content.encode("utf-8")
-
-    # req.content_type = "text/plain;charset=utf-8"
-    response_headers = [
-        ("Content-type", "text/plain;charset=utf-8"),
-        ("Content-Length", str(len(content))),
-    ]
-    start_response(status, response_headers)
-
-    return [content]


Test patch: