ULN plugin broken in 2020.06
When attempting to synchronise an Oracle ULN repository, the ULN plugin outputs the following error.

Command:
   # spacewalk-repo-sync --type uln --channel=ol7_x86_64_u8_base --url=uln:///ol7_x86_64_u8_base

Initial error:
  ModuleNotFoundError: No module named 'spacewalk.satellite_tools.ulnauth'

Added missing ulnauth.py from GitHub uyubu-projects satellite_tools:
   /usr/lib/python3.6/site-packages/spacewalk/satellite_tools/ulnauth.py


Re-ran command, new error:

  10:16:54 ======================================
  10:16:54 | Channel: ol7_x86_64_u8_base
  10:16:54 ======================================
  10:16:54 Sync of channel started.
  Source_URL:  uln:/ol7_x86_64_u8_base
  10:16:54 Unexpected error: <class 'Exception'>
  10:16:54 Traceback (most recent call last):
  File "/usr/lib/python3.6/site-packages/spacewalk/satellite_tools/reposync.py", line 509, in sync
      raise Exception("Unknown protocol in repo URL: %s" % url)
  Exception: Unknown protocol in repo URL: uln:/ol7_x86_64_u8_base


diff --git a/backend/common/suseLib.py b/backend/common/suseLib.py
index 0b1c939d810..5ba65c16178 100644
--- a/backend/common/suseLib.py
+++ b/backend/common/suseLib.py
@@ -112,6 +112,7 @@ class URL(object):
     def getURL(self, stripPw=False):
         """Return the full url as a string"""
         netloc = ""
+        path = self.path
         if self.username:
             netloc = self.username
         if self.password and not stripPw:
@@ -126,7 +127,13 @@ class URL(object):
         if self.port:
             netloc = '%s:%s' % (netloc, self.port)
 
-        return urlparse.urlunsplit((self.scheme, netloc, self.path,
+        # Default ULN channels URIs are like: uln:///ol7_x86_64_u8_base
+        # If not netloc, we fix the path to avoid getting url:/ol7_x86_64_u8_base
+        # as results from urlunsplit
+        if self.scheme == 'uln' and not netloc:
+            path = "//" + self.path
+
+        return urlparse.urlunsplit((self.scheme, netloc, path,
                                     self.query, self.fragment))
 
 
@@ -621,7 +628,7 @@ def _useProxyFor(url):
     u = urlparse.urlsplit(url)
     # pylint can't see inside the SplitResult class
     # pylint: disable=E1103
-    if u.scheme == 'file':
+    if u.scheme == 'file' or not u.hostname:
         return False
     hostname = u.hostname.lower()
     if hostname in ["localhost", "127.0.0.1", "::1"]:
diff --git a/backend/satellite_tools/repo_plugins/uln_src.py b/backend/satellite_tools/repo_plugins/uln_src.py
index 6ea2e7d2f82..3f79ba45939 100644
--- a/backend/satellite_tools/repo_plugins/uln_src.py
+++ b/backend/satellite_tools/repo_plugins/uln_src.py
@@ -34,6 +34,9 @@ class ContentSource(yum_ContentSource):
                  ca_cert_file=None, client_cert_file=None, client_key_file=None):
         if url[:6] != "uln://":
             raise RhnSyncException("url format error, url must start with uln://")
+        # Make sure baseurl ends with / and urljoin will work correctly
+        if url[-1] != '/':
+            url += '/'
         yum_ContentSource.__init__(self, url=url, name=name, insecure=insecure,
                                    interactive=interactive, yumsrc_conf=yumsrc_conf, org=org,
                                    channel_label=channel_label, no_mirrors=no_mirrors,
@@ -46,12 +49,25 @@ class ContentSource(yum_ContentSource):
         self._uln_auth = ULNAuth()
         self.uln_token = self._uln_auth.authenticate(url)
         self.http_headers = {'X-ULN-Api-User-Key': self.uln_token}
-        hostname, label = self._uln_auth.get_hostname(url)
-        self.url = self._uln_auth.url + "/XMLRPC/GET-REQ/" + label
-        print(("The download URL is: " + self.url))
+        hostname, label = self._uln_auth.get_hostname(self.url)
+        self._load_proxy_settings(hostname)
+        print(("The download URL is: " + self._uln_auth.url + "/XMLRPC/GET-REQ/" + label))
         if self.proxy_url:
             print(("Trying proxy " + self.proxy_url))
 
     # pylint: disable=arguments-differ
     def setup_repo(self, repo, *args, **kwargs):
         yum_ContentSource.setup_repo(self, repo, *args, uln_repo=True, **kwargs)
+
+    # Get download parameters for threaded downloader
+    def set_download_parameters(self, params, relative_path, target_file, checksum_type=None,
+                                checksum_value=None, bytes_range=None):
+        yum_ContentSource.set_download_parameters(self,
+                                                  params=params,
+                                                  relative_path=relative_path,
+                                                  target_file=target_file,
+                                                  checksum_type=checksum_type,
+                                                  checksum_value=checksum_value,
+                                                  bytes_range=bytes_range)
+        hostname, label = self._uln_auth.get_hostname(self.url)
+        params['urls'] = [self._uln_auth.url + "/XMLRPC/GET-REQ/" + label]
diff --git a/backend/satellite_tools/repo_plugins/yum_src.py b/backend/satellite_tools/repo_plugins/yum_src.py
index 58f7b34a92f..51873f05792 100644
--- a/backend/satellite_tools/repo_plugins/yum_src.py
+++ b/backend/satellite_tools/repo_plugins/yum_src.py
@@ -415,7 +415,7 @@ class ContentSource:
         self.http_headers = {}
 
         comp = CFG.getComponent()
-        # read the proxy configuration in /etc/rhn/rhn.conf
+        # read configuration from /etc/rhn/rhn.conf
         initCFG('server.satellite')
 
         # keep authtokens for mirroring
@@ -423,33 +423,8 @@ class ContentSource:
         if query:
             self.authtoken = query
 
-        # Get the global HTTP Proxy settings from DB or per-repo
-        # settings on /etc/rhn/spacewalk-repo-sync/zypper.conf
-        if CFG.http_proxy:
-            self.proxy_url, self.proxy_user, self.proxy_pass = get_proxy(self.url)
-            self.proxy_hostname = self.proxy_url
-        elif os.path.isfile(REPOSYNC_ZYPPER_CONF):
-            zypper_cfg = configparser.ConfigParser()
-            zypper_cfg.read_file(open(REPOSYNC_ZYPPER_CONF))
-            section_name = None
-
-            if zypper_cfg.has_section(self.name):
-                section_name = self.name
-            elif zypper_cfg.has_section(channel_label):
-                section_name = channel_label
-            elif zypper_cfg.has_section('main'):
-                section_name = 'main'
-
-            if section_name:
-                if zypper_cfg.has_option(section_name, option='proxy'):
-                    self.proxy_hostname = zypper_cfg.get(section_name, option='proxy')
-                    self.proxy_url = "http://%s" % self.proxy_hostname
-
-                if zypper_cfg.has_option(section_name, 'proxy_username'):
-                    self.proxy_user = zypper_cfg.get(section_name, 'proxy_username')
-
-                if zypper_cfg.has_option(section_name, 'proxy_password'):
-                    self.proxy_pass = zypper_cfg.get(section_name, 'proxy_password')
+        # load proxy configuration based on the url
+        self._load_proxy_settings(self.url)
 
         # Get extra HTTP headers configuration from /etc/rhn/spacewalk-repo-sync/extra_headers.conf
         if os.path.isfile(REPOSYNC_EXTRA_HTTP_HEADERS_CONF):
@@ -468,6 +443,7 @@ class ContentSource:
                 for hdr in http_headers_cfg[section_name]:
                     self.http_headers[hdr] = http_headers_cfg.get(section_name, option=hdr)
 
+        # perform authentication if implemented
         self._authenticate(url)
 
         # Make sure baseurl ends with / and urljoin will work correctly
@@ -491,6 +467,42 @@ class ContentSource:
         # set config component back to original
         initCFG(comp)
 
+    def _load_proxy_settings(self, url):
+        # read the proxy configuration in /etc/rhn/rhn.conf
+        comp = CFG.getComponent()
+        initCFG('server.satellite')
+
+        # Get the global HTTP Proxy settings from DB or per-repo
+        # settings on /etc/rhn/spacewalk-repo-sync/zypper.conf
+        if CFG.http_proxy:
+            self.proxy_url, self.proxy_user, self.proxy_pass = get_proxy(url)
+            self.proxy_hostname = self.proxy_url
+        elif os.path.isfile(REPOSYNC_ZYPPER_CONF):
+            zypper_cfg = configparser.ConfigParser()
+            zypper_cfg.read_file(open(REPOSYNC_ZYPPER_CONF))
+            section_name = None
+
+            if zypper_cfg.has_section(self.name):
+                section_name = self.name
+            elif zypper_cfg.has_section(channel_label):
+                section_name = channel_label
+            elif zypper_cfg.has_section('main'):
+                section_name = 'main'
+
+            if section_name:
+                if zypper_cfg.has_option(section_name, option='proxy'):
+                    self.proxy_hostname = zypper_cfg.get(section_name, option='proxy')
+                    self.proxy_url = "http://%s" % self.proxy_hostname
+
+                if zypper_cfg.has_option(section_name, 'proxy_username'):
+                    self.proxy_user = zypper_cfg.get(section_name, 'proxy_username')
+
+                if zypper_cfg.has_option(section_name, 'proxy_password'):
+                    self.proxy_pass = zypper_cfg.get(section_name, 'proxy_password')
+
+        # set config component back to original
+        initCFG(comp)
+
     def _get_mirror_list(self, repo, url):
         mirrorlist_path = os.path.join(repo.root, 'mirrorlist.txt')
         returnlist = []
@@ -568,7 +580,7 @@ class ContentSource:
         Setup repository and fetch metadata
         """
         self.zypposync = ZyppoSync(root=repo.root)
-        zypp_repo_url = self._prep_zypp_repo_url(self.url)
+        zypp_repo_url = self._prep_zypp_repo_url(self.url, uln_repo)
 
         mirrorlist = self._get_mirror_list(repo, self.url)
         if mirrorlist:
@@ -618,7 +630,7 @@ type=rpm-md
         rhnLog.log_clean(0, message)
         sys.stderr.write(str(message) + "\n")
 
-    def _prep_zypp_repo_url(self, url):
+    def _prep_zypp_repo_url(self, url, uln_repo):
         """
         Prepare the repository baseurl to use in the Zypper repo file.
         This will add the HTTP Proxy and Client certificate settings as part of
@@ -649,7 +661,7 @@ type=rpm-md
         if self.sslclientkey:
             query_params['ssl_clientkey'] = self.sslclientkey
         new_query = unquote(urlencode(query_params, doseq=True))
-        if self.authtoken:
+        if self.authtoken or uln_repo:
             ret_url = "{0}&{1}".format(url, new_query)
         else:
             ret_url = "{0}?{1}".format(url, new_query) if new_query else url
diff --git a/backend/satellite_tools/spacewalk-uln-resolver b/backend/satellite_tools/spacewalk-uln-resolver
index a14005286b4..ca447cfb933 100755
--- a/backend/satellite_tools/spacewalk-uln-resolver
+++ b/backend/satellite_tools/spacewalk-uln-resolver
@@ -39,8 +39,10 @@ class SpacewalkULNPlugin(Plugin):
             }
         except Exception as exc:
             self.answer("ERROR", {}, str(exc))
-        hostname, label = self._uln_auth.get_hostname(headers['url'])
+        hostname, label = self._uln_auth.get_hostname(headers.pop('url'))
         url = self._uln_auth.url + "/XMLRPC/GET-REQ/" + label
+        if headers:
+           url += "?{}".format(urllib.parse.urlencode(headers))
         self.answer("RESOLVEDURL", self.auth_headers, url)
 
 plugin = SpacewalkULNPlugin()
diff --git a/backend/spacewalk-backend.changes b/backend/spacewalk-backend.changes
index 1ecc584d7ca..c1d0c57dbe4 100644
--- a/backend/spacewalk-backend.changes
+++ b/backend/spacewalk-backend.changes
@@ -1,3 +1,4 @@
+- Fix spacewalk-repo-sync to successfully manage and sync ULN repositories
 - fix errors in spacewalk-debug and align postgresql queries to new DB version
 - ISS: Differentiate packages with same nevra but different checksum in the same channel (bsc#1178195)
 - add 'allow_vendor_change' option to rhn clients for dist upgrades
diff --git a/java/code/src/com/redhat/rhn/manager/channel/repo/BaseRepoCommand.java b/java/code/src/com/redhat/rhn/manager/channel/repo/BaseRepoCommand.java
index 10bb7cce6d6..48cf4f627b5 100644
--- a/java/code/src/com/redhat/rhn/manager/channel/repo/BaseRepoCommand.java
+++ b/java/code/src/com/redhat/rhn/manager/channel/repo/BaseRepoCommand.java
@@ -31,6 +31,7 @@ import com.redhat.rhn.frontend.xmlrpc.channel.repo.InvalidRepoUrlException;
 import com.redhat.rhn.frontend.xmlrpc.channel.repo.InvalidRepoUrlInputException;
 
 import java.net.URL;
+import java.net.URI;
 import java.util.Date;
 import java.util.HashSet;
 import java.util.Set;
@@ -219,9 +220,23 @@ public abstract class BaseRepoCommand {
         }
 
         if (this.url != null && this.type != null) {
-            final URL u;
             try {
-                u = new URL(this.url);
+                final URL u;
+                if (this.type.equals("uln")) {
+                    // URL for ULN repositories, i.a. uln:///uln_channel_label, are not
+                    // passing Java URL validation due unknown protocol. We fake the
+                    // protocol to "file" and run the validation for the rest of the URL.
+                    final URI uri = new URI(this.url);
+                    if (uri.getScheme().equals("uln")) {
+                       u = new URI("file", uri.getSchemeSpecificPart(), uri.getFragment()).toURL();
+                    }
+                    else {
+                        throw new InvalidRepoUrlInputException(url);
+                    }
+                }
+                else {
+                    u = new URL(this.url);
+                }
             }
             catch (Exception e) {
                 throw new InvalidRepoUrlInputException(url);
diff --git a/java/code/src/com/redhat/rhn/manager/channel/test/BaseRepoCommandTest.java b/java/code/src/com/redhat/rhn/manager/channel/test/BaseRepoCommandTest.java
index 8d1e02db7b5..30717ab53a1 100644
--- a/java/code/src/com/redhat/rhn/manager/channel/test/BaseRepoCommandTest.java
+++ b/java/code/src/com/redhat/rhn/manager/channel/test/BaseRepoCommandTest.java
@@ -43,64 +43,74 @@ public class BaseRepoCommandTest extends RhnBaseTestCase {
         // Url must begin with a valid protocol (http, https, ftp, ...),
         //  end with a valid TLD and contain only valid characters
 
-        // I N V A L I D
-        invalidUrlInput("");
-        invalidUrlInput("example.com");
-        invalidUrlInput("htp://some_test_url.com");
-        invalidUrlInput("www.example.com");
+        // YUM - I N V A L I D
+        invalidUrlInput("", "yum");
+        invalidUrlInput("example.com", "yum");
+        invalidUrlInput("htp://some_test_url.com", "yum");
+        invalidUrlInput("www.example.com", "yum");
 
-        // V A L I D
-        validUrlInput("file:///srv/mirror");
-        validUrlInput("http://localhost");
-        validUrlInput("http://localhost:8080");
-        validUrlInput("http://localhost/pub");
-        validUrlInput("http://localhost.com.x/pub");
-        validUrlInput("http://www.example.com");
-        validUrlInput("http://www.example.co.uk");
-        validUrlInput("https://www3.example.com");
-        validUrlInput("https://test123.com");
-        validUrlInput("http://example.com");
-        validUrlInput("http://another-one.de/example");
-        validUrlInput("https://another-one.de/example%20");
-        validUrlInput("http://another-one.de/example?v=10");
-        validUrlInput("ftp://exampleABC.com");
+        // YUM - V A L I D
+        validUrlInput("file:///srv/mirror", "yum");
+        validUrlInput("http://localhost", "yum");
+        validUrlInput("http://localhost:8080", "yum");
+        validUrlInput("http://localhost/pub", "yum");
+        validUrlInput("http://localhost.com.x/pub", "yum");
+        validUrlInput("http://www.example.com", "yum");
+        validUrlInput("http://www.example.co.uk", "yum");
+        validUrlInput("https://www3.example.com", "yum");
+        validUrlInput("https://test123.com", "yum");
+        validUrlInput("http://example.com", "yum");
+        validUrlInput("http://another-one.de/example", "yum");
+        validUrlInput("https://another-one.de/example%20", "yum");
+        validUrlInput("http://another-one.de/example?v=10", "yum");
+        validUrlInput("ftp://exampleABC.com", "yum");
+
+        // ULN - V A L I D
+        validUrlInput("uln://foo/example_label", "uln");
+        validUrlInput("uln:///example_label", "uln");
+
+        // ULN - I N V A L I D
+        invalidUrlInput("", "uln");
+        invalidUrlInput("example.com", "uln");
+        invalidUrlInput("htp://some_test_url.com", "uln");
+        invalidUrlInput("www.example.com", "uln");
     }
 
-    private void invalidUrlInput(String url) {
+    private void invalidUrlInput(String url, String type) {
         // give it an invalid url
         ccc.setUrl(url);
         // give it a valid label
         ccc.setLabel("valid-label-name");
         // need to specify a type
-        ccc.setType("yum");
+        ccc.setType(type);
         // need to specify MetadataSigned
         ccc.setMetadataSigned(Boolean.FALSE);
 
         try {
             ccc.store();
-            fail("invalid url should have thrown error");
+            fail("invalid url should have thrown error: " + url);
         }
         catch (InvalidRepoUrlException e) {
-            fail("non duplicate url caused error");
+            fail("non duplicate url caused error: " + url);
         }
         catch (InvalidRepoUrlInputException expected) {
             // expected
         }
         catch (InvalidRepoLabelException e) {
-            fail("valid repo label caused error");
+            fail("valid repo label caused error: " + url);
         }
         catch (InvalidRepoTypeException e) {
-            fail("valid repo type caused error");
+            fail("valid repo type caused error: " + url);
         }
     }
 
-    private void validUrlInput(String url) {
+    private void validUrlInput(String url, String type) {
         // give it a valid url
         ccc.setUrl(url);
         // need to create unique label names.
         ccc.setLabel("valid-label-name-" + label_count++);
         // need to specify a type
-        ccc.setType("yum");
+        ccc.setType(type);
         // need to specify MetadataSigned
         ccc.setMetadataSigned(Boolean.FALSE);
 
@@ -108,16 +118,16 @@ public class BaseRepoCommandTest extends RhnBaseTestCase {
             ccc.store();
         }
         catch (InvalidRepoUrlException e) {
-            fail("non duplicate url caused error");
+            fail("non duplicate url caused error: " + url);
         }
         catch (InvalidRepoUrlInputException e) {
-            fail("valid repo url input caused error");
+            fail("valid repo url input caused error: " + url);
         }
         catch (InvalidRepoLabelException e) {
-            fail("valid repo label caused error");
+            fail("valid repo label caused error: " + url);
         }
         catch (InvalidRepoTypeException e) {
-            fail("valid repo type caused error");
+            fail("valid repo type caused error: " + url);
         }
     }
 }
diff --git a/java/spacewalk-java.changes b/java/spacewalk-java.changes
index 1a1e880b97f..fa182a70e9f 100644
--- a/java/spacewalk-java.changes
+++ b/java/spacewalk-java.changes
@@ -1,3 +1,4 @@
+- Allow creating custom ULN repositories with uln:// urls
 - Change message "Minion is down" to be more accurate
 - Revert: Sync state modules when starting action chain execution (bsc#1177336)
 - Remove expiration date from ics files (bsc#1177892)
['backend/common/suseLib.py', 'java/code/src/com/redhat/rhn/manager/channel/test/BaseRepoCommandTest.java', 'backend/satellite_tools/spacewalk-uln-resolver', 'backend/spacewalk-backend.changes', 'java/spacewalk-java.changes', 'backend/satellite_tools/repo_plugins/yum_src.py', 'java/code/src/com/redhat/rhn/manager/channel/repo/BaseRepoCommand.java', 'backend/satellite_tools/repo_plugins/uln_src.py']