diff -rupN a/sos/__init__.py b/sos/__init__.py
--- a/sos/__init__.py
+++ b/sos/__init__.py
@@ -201,6 +201,7 @@ class SoSOptions(object):
self.upload_directory = ""
self.upload_user = ""
self.upload_pass = ""
+ self.upload_method = "auto"
self.upload_protocol = _arg_defaults["upload_protocol"]
self.verbosity = _arg_defaults["verbosity"]
self.verify = False
diff -rupN a/sos/policies/auth/__init__.py b/sos/policies/auth/__init__.py
--- /dev/null
+++ b/sos/policies/auth/__init__.py
@@ -0,0 +1,205 @@
+# Copyright (C) 2023 Red Hat, Inc., Jose Castillo <jcastillo@redhat.com>
+
+# This file is part of the sos project: https://github.com/sosreport/sos
+#
+# This copyrighted material is made available to anyone wishing to use,
+# modify, copy, or redistribute it subject to the terms and conditions of
+# version 2 of the GNU General Public License.
+#
+# See the LICENSE file in the source distribution for further information.
+
+import logging
+try:
+ import requests
+ REQUESTS_LOADED = True
+except ImportError:
+ REQUESTS_LOADED = False
+import time
+from datetime import datetime, timedelta
+
+DEVICE_AUTH_CLIENT_ID = "sos-tools"
+GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
+
+logger = logging.getLogger("sos")
+
+
+class DeviceAuthorizationClass:
+ """
+ Device Authorization Class
+ """
+
+ def __init__(self, client_identifier_url, token_endpoint):
+
+ self._access_token = None
+ self._access_expires_at = None
+ self.__device_code = None
+
+ self.client_identifier_url = client_identifier_url
+ self.token_endpoint = token_endpoint
+ self._use_device_code_grant()
+
+ def _use_device_code_grant(self):
+ """
+ Start the device auth flow. In the future we will
+ store the tokens in an in-memory keyring.
+ """
+
+ self._request_device_code()
+ print(
+ "Please visit the following URL to authenticate this device: {}"
+ .format(self._verification_uri_complete)
+ )
+ self.poll_for_auth_completion()
+
+ def _request_device_code(self):
+ """
+ Initialize new Device Authorization Grant attempt by
+ requesting a new device code.
+ """
+ data = "client_id={}".format(DEVICE_AUTH_CLIENT_ID)
+ headers = {'content-type': 'application/x-www-form-urlencoded'}
+ if not REQUESTS_LOADED:
+ raise Exception("python3-requests is not installed and is required"
+ " for obtaining device auth token.")
+ try:
+ res = requests.post(
+ self.client_identifier_url,
+ data=data,
+ headers=headers)
+ res.raise_for_status()
+ response = res.json()
+ self._user_code = response.get("user_code")
+ self._verification_uri = response.get("verification_uri")
+ self._interval = response.get("interval")
+ self.__device_code = response.get("device_code")
+ self._verification_uri_complete = response.get(
+ "verification_uri_complete")
+ except requests.HTTPError as e:
+ raise requests.HTTPError(
+ "HTTP request failed while attempting to acquire the tokens."
+ " Error returned was {}".format(res.status_code)
+ )
+
+ def poll_for_auth_completion(self):
+ """
+ Continuously poll OIDC token endpoint until the user is successfully
+ authenticated or an error occurs.
+ """
+ token_data = {'grant_type': GRANT_TYPE_DEVICE_CODE,
+ 'client_id': DEVICE_AUTH_CLIENT_ID,
+ 'device_code': self.__device_code}
+
+ if not REQUESTS_LOADED:
+ raise Exception("python3-requests is not installed and is required"
+ " for obtaining device auth token.")
+ while self._access_token is None:
+ time.sleep(self._interval)
+ try:
+ check_auth_completion = requests.post(self.token_endpoint,
+ data=token_data)
+
+ status_code = check_auth_completion.status_code
+
+ if status_code == 200:
+ logger.info("The SSO authentication is successful")
+ self._set_token_data(check_auth_completion.json())
+ if status_code not in [200, 400]:
+ raise Exception(status_code, check_auth_completion.text)
+ if status_code == 400 and \
+ check_auth_completion.json()['error'] not in \
+ ("authorization_pending", "slow_down"):
+ raise Exception(status_code, check_auth_completion.text)
+ except requests.exceptions.RequestException as e:
+ logger.error("Error was found while posting a request: {}"
+ .format(e))
+
+ def _set_token_data(self, token_data):
+ """
+ Set the class attributes as per the input token_data received.
+ In the future we will persist the token data in a local,
+ in-memory keyring, to avoid visting the browser frequently.
+ :param token_data: Token data containing access_token, refresh_token
+ and their expiry etc.
+ """
+ self._access_token = token_data.get("access_token")
+ self._access_expires_at = datetime.utcnow() + \
+ timedelta(seconds=token_data.get("expires_in"))
+ self._refresh_token = token_data.get("refresh_token")
+ self._refresh_expires_in = token_data.get("refresh_expires_in")
+ if self._refresh_expires_in == 0:
+ self._refresh_expires_at = datetime.max
+ else:
+ self._refresh_expires_at = datetime.utcnow() + \
+ timedelta(seconds=self._refresh_expires_in)
+
+ def get_access_token(self):
+ """
+ Get the valid access_token at any given time.
+ :return: Access_token
+ :rtype: string
+ """
+ if self.is_access_token_valid():
+ return self._access_token
+ else:
+ if self.is_refresh_token_valid():
+ self._use_refresh_token_grant()
+ return self._access_token
+ else:
+ self._use_device_code_grant()
+ return self._access_token
+
+ def is_access_token_valid(self):
+ """
+ Check the validity of access_token. We are considering it invalid 180
+ sec. prior to it's exact expiry time.
+ :return: True/False
+ """
+ return self._access_token and self._access_expires_at and \
+ self._access_expires_at - timedelta(seconds=180) > \
+ datetime.utcnow()
+
+ def is_refresh_token_valid(self):
+ """
+ Check the validity of refresh_token. We are considering it invalid
+ 180 sec. prior to it's exact expiry time.
+ :return: True/False
+ """
+ return self._refresh_token and self._refresh_expires_at and \
+ self._refresh_expires_at - timedelta(seconds=180) > \
+ datetime.utcnow()
+
+ def _use_refresh_token_grant(self, refresh_token=None):
+ """
+ Fetch the new access_token and refresh_token using the existing
+ refresh_token and persist it.
+ :param refresh_token: optional param for refresh_token
+ """
+ if not REQUESTS_LOADED:
+ raise Exception("python3-requests is not installed and is required"
+ " for obtaining device auth token.")
+ refresh_token_data = {'client_id': DEVICE_AUTH_CLIENT_ID,
+ 'grant_type': 'refresh_token',
+ 'refresh_token': self._refresh_token if not
+ refresh_token else refresh_token}
+
+ refresh_token_res = requests.post(self.token_endpoint,
+ data=refresh_token_data)
+
+ if refresh_token_res.status_code == 200:
+ self._set_token_data(refresh_token_res.json())
+
+ elif refresh_token_res.status_code == 400 and 'invalid' in\
+ refresh_token_res.json()['error']:
+ logger.warning("Problem while fetching the new tokens from refresh"
+ " token grant - {} {}."
+ " New Device code will be requested !".format
+ (refresh_token_res.status_code,
+ refresh_token_res.json()['error']))
+ self._use_device_code_grant()
+ else:
+ raise Exception(
+ "Something went wrong while using the "
+ "Refresh token grant for fetching tokens:"
+ "Returned status code {0} and error {1}"
+ .format(refresh_token_res.status_code,
+ refresh_token_res.json()['error']))
diff -rupN a/sos/policies/__init__.py b/sos/policies/__init__.py
--- a/sos/policies/__init__.py
+++ b/sos/policies/__init__.py
@@ -861,7 +861,7 @@ class LinuxPolicy(Policy):
_upload_directory = '/'
_upload_user = None
_upload_password = None
- _use_https_streaming = False
+ _upload_method = None
_preferred_hash_name = None
upload_url = None
upload_user = None
@@ -1011,8 +1011,6 @@ class LinuxPolicy(Policy):
these MUST include protocol header
_upload_user Default username, if any else None
_upload_password Default password, if any else None
- _use_https_streaming Set to True if the HTTPS endpoint
- supports streaming data
Optional:
Class Attrs:
@@ -1198,7 +1196,9 @@ class LinuxPolicy(Policy):
elif put_success == 2:
raise Exception("Unknown error during upload: %s" % ret.before)
elif put_success == 3:
- raise Exception("Unable to write archive to destination")
+ raise Exception(
+ "Unable to write archive to destination %s" % ret.before
+ )
else:
raise Exception("Unexpected response from server: %s" % ret.before)
@@ -1212,9 +1212,8 @@ class LinuxPolicy(Policy):
"""
return self.upload_archive_name.split('/')[-1]
- def _upload_https_streaming(self, archive):
- """If upload_https() needs to use requests.put(), this method is used
- to provide streaming functionality
+ def _upload_https_put(self, archive):
+ """If upload_https() needs to use requests.put(), use this method.
Policies should override this method instead of the base upload_https()
@@ -1229,9 +1228,8 @@ class LinuxPolicy(Policy):
"""
return {}
- def _upload_https_no_stream(self, archive):
- """If upload_https() needs to use requests.post(), this method is used
- to provide non-streaming functionality
+ def _upload_https_post(self, archive):
+ """If upload_https() needs to use requests.post(), use this method.
Policies should override this method instead of the base upload_https()
@@ -1247,20 +1245,20 @@ class LinuxPolicy(Policy):
def upload_https(self):
"""Attempts to upload the archive to an HTTPS location.
-
- Policies may define whether this upload attempt should use streaming
- or non-streaming data by setting the `use_https_streaming` class
- attr to True
"""
if not REQUESTS_LOADED:
raise Exception("Unable to upload due to missing python requests "
"library")
with open(self.upload_archive_name, 'rb') as arc:
- if not self._use_https_streaming:
- r = self._upload_https_no_stream(arc)
+ if self.commons['cmdlineopts'].upload_method == 'auto':
+ method = self._upload_method
+ else:
+ method = self.commons['cmdlineopts'].upload_method
+ if method == 'put':
+ r = self._upload_https_put(arc)
else:
- r = self._upload_https_streaming(arc)
+ r = self._upload_https_post(arc)
if r.status_code != 200 and r.status_code != 201:
if r.status_code == 401:
raise Exception(
@@ -1313,9 +1311,13 @@ class LinuxPolicy(Policy):
errno = str(err).split()[0]
if errno == '503':
raise Exception("could not login as '%s'" % user)
+ if errno == '530':
+ raise Exception("invalid password for user '%s'" % user)
if errno == '550':
raise Exception("could not set upload directory to %s"
% directory)
+ raise Exception("error trying to establish session: %s"
+ % str(err))
try:
with open(self.upload_archive_name, 'rb') as _arcfile:
diff -rupN a/sos/policies/redhat.py b/sos/policies/redhat.py
--- a/sos/policies/redhat.py
+++ b/sos/policies/redhat.py
@@ -14,12 +14,16 @@ import json
import os
import sys
import re
+import logging
+from sos.policies.auth import DeviceAuthorizationClass
-from sos.plugins import RedHatPlugin
+from sos.plugins import Plugin, RedHatPlugin
from sos.policies import LinuxPolicy, PackageManager, PresetDefaults
from sos import _sos as _
from sos import SoSOptions
+logger = logging.getLogger("sos")
+
try:
import requests
REQUESTS_LOADED = True
@@ -53,6 +57,10 @@ class RedHatPolicy(LinuxPolicy):
name_pattern = 'friendly'
upload_url = None
upload_user = None
+ client_identifier_url = "https://sso.redhat.com/auth/"\
+ "realms/redhat-external/protocol/openid-connect/auth/device"
+ token_endpoint = "https://sso.redhat.com/auth/realms/"\
+ "redhat-external/protocol/openid-connect/token"
def __init__(self, sysroot=None):
super(RedHatPolicy, self).__init__(sysroot=sysroot)
@@ -275,6 +283,8 @@ generated in %(tmpdir)s and may be provi
support representative.
""" + disclaimer_text + "%(vendor_text)s\n")
_upload_url = RH_SFTP_HOST
+ _device_token = None
+ _upload_method = 'post'
def __init__(self, sysroot=None):
super(RHELPolicy, self).__init__(sysroot=sysroot)
@@ -306,16 +316,23 @@ support representative.
def prompt_for_upload_user(self):
if self.commons['cmdlineopts'].upload_user:
- return
- # Not using the default, so don't call this prompt for RHCP
- if self.commons['cmdlineopts'].upload_url:
- super(RHELPolicy, self).prompt_for_upload_user()
- return
- if self.case_id:
- self.upload_user = input(_(
- "Enter your Red Hat Customer Portal username for uploading ["
- "empty for anonymous SFTP]: ")
+ logger.info(
+ _("The option --upload-user has been deprecated in favour"
+ " of device authorization in RHEL")
+ )
+ if not self.case_id:
+ # no case id provided => failover to SFTP
+ self.upload_url = RH_SFTP_HOST
+ logger.info("No case id provided, uploading to SFTP")
+
+ def prompt_for_upload_password(self):
+ # With OIDC we don't ask for user/pass anymore
+ if self.commons['cmdlineopts'].upload_pass:
+ logger.info(
+ _("The option --upload-pass has been deprecated in favour"
+ " of device authorization in RHEL")
)
+ return
def get_upload_url(self):
if self.upload_url:
@@ -324,10 +341,40 @@ support representative.
return self.commons['cmdlineopts'].upload_url
elif self.commons['cmdlineopts'].upload_protocol == 'sftp':
return RH_SFTP_HOST
+ elif not self.commons['cmdlineopts'].case_id:
+ logger.info("No case id provided, uploading to SFTP")
+ return RH_SFTP_HOST
else:
rh_case_api = "/support/v1/cases/%s/attachments"
return RH_API_HOST + rh_case_api % self.case_id
+ def _get_upload_https_auth(self):
+ str_auth = "Bearer {}".format(self._device_token)
+ return {'Authorization': str_auth}
+
+ def _upload_https_post(self, archive, verify=True):
+ """If upload_https() needs to use requests.post(), use this method.
+ Policies should override this method instead of the base upload_https()
+ :param archive: The open archive file object
+ """
+ files = {
+ 'file': (archive.name.split('/')[-1], archive,
+ self._get_upload_headers())
+ }
+ # Get the access token at this point. With this,
+ # we cover the cases where report generation takes
+ # longer than the token timeout
+ RHELAuth = DeviceAuthorizationClass(
+ self.client_identifier_url,
+ self.token_endpoint
+ )
+ self._device_token = RHELAuth.get_access_token()
+ logger.info("Device authorized correctly. Uploading file to {}"
+ .format(self.get_upload_url_string()))
+ return requests.post(self.get_upload_url(), files=files,
+ headers=self._get_upload_https_auth(),
+ verify=verify)
+
def _get_upload_headers(self):
if self.get_upload_url().startswith(RH_API_HOST):
return {'isPrivate': 'false', 'cache-control': 'no-cache'}
@@ -362,21 +409,43 @@ support representative.
" for obtaining SFTP auth token.")
_token = None
_user = None
+
+ # We may have a device token already if we attempted
+ # to upload via http but the upload failed. So
+ # lets check first if there isn't one.
+ if not self._device_token:
+ try:
+ RHELAuth = DeviceAuthorizationClass(
+ self.client_identifier_url,
+ self.token_endpoint
+ )
+ except Exception as e:
+ # We end up here if the user cancels the device
+ # authentication in the web interface
+ if "end user denied" in str(e):
+ logger.info(
+ "Device token authorization "
+ "has been cancelled by the user."
+ )
+ else:
+ self._device_token = RHELAuth.get_access_token()
+ if self._device_token:
+ logger.info("Device authorized correctly. Uploading file to {}"
+ .format(self.get_upload_url_string()))
+
url = RH_API_HOST + '/support/v2/sftp/token'
- # we have a username and password, but we need to reset the password
- # to be the token returned from the auth endpoint
- if self.get_upload_user() and self.get_upload_password():
- auth = self.get_upload_https_auth()
- ret = requests.post(url, auth=auth, timeout=10)
+ ret = None
+ if self._device_token:
+ headers = self._get_upload_https_auth()
+ ret = requests.post(url, headers=headers, timeout=10)
if ret.status_code == 200:
# credentials are valid
- _user = self.get_upload_user()
+ _user = json.loads(ret.text)['username']
_token = json.loads(ret.text)['token']
else:
print("Unable to retrieve Red Hat auth token using provided "
"credentials. Will try anonymous.")
- # we either do not have a username or password/token, or both
- if not _token:
+ else:
adata = {"isAnonymous": True}
anon = requests.post(url, data=json.dumps(adata), timeout=10)
if anon.status_code == 200:
@@ -395,16 +464,17 @@ support representative.
from RHCP failures to the public RH dropbox
"""
try:
- if not self.get_upload_user() or not self.get_upload_password():
+ if self.upload_url and self.upload_url.startswith(RH_API_HOST) and\
+ (not self.get_upload_user() or not self.get_upload_password()):
self.upload_url = RH_SFTP_HOST
uploaded = super(RHELPolicy, self).upload_archive(archive)
- except Exception:
+ except Exception as e:
uploaded = False
if not self.upload_url.startswith(RH_API_HOST):
raise
else:
- print("Upload to Red Hat Customer Portal failed. Trying %s"
- % RH_SFTP_HOST)
+ print("Upload to Red Hat Customer Portal failed due to %s. "
+ "Trying %s" % (e, RH_SFTP_HOST))
self.upload_url = RH_SFTP_HOST
uploaded = super(RHELPolicy, self).upload_archive(archive)
return uploaded
diff -rupN a/sos/policies/ubuntu.py b/sos/policies/ubuntu.py
--- a/sos/policies/ubuntu.py
+++ b/sos/policies/ubuntu.py
@@ -15,7 +15,7 @@ class UbuntuPolicy(DebianPolicy):
_upload_url = "https://files.support.canonical.com/uploads/"
_upload_user = "ubuntu"
_upload_password = "ubuntu"
- _use_https_streaming = True
+ _upload_method = 'put'
def __init__(self, sysroot=None):
super(UbuntuPolicy, self).__init__(sysroot=sysroot)
diff -rupN a/sos/sosreport.py b/sos/sosreport.py
--- a/sos/sosreport.py
+++ b/sos/sosreport.py
@@ -255,6 +255,9 @@ def _get_parser():
help="Username to authenticate to upload server with")
parser.add_argument("--upload-pass", default=None,
help="Password to authenticate to upload server with")
+ parser.add_argument("--upload-method", default='auto',
+ choices=['auto', 'put', 'post'],
+ help="HTTP method to use for uploading")
parser.add_argument("--upload-protocol", default='auto',
choices=['auto', 'https', 'ftp', 'sftp'],
help="Manually specify the upload protocol")
diff -rupN a/man/en/sosreport.1 b/man/en/sosreport.1
--- a/man/en/sosreport.1
+++ b/man/en/sosreport.1
@@ -315,6 +315,13 @@ when prompted rather than using this opt
Specify a directory to upload to, if one is not specified by a vendor default location
or if your destination server does not allow writes to '/'.
.TP
+.B \--upload-method METHOD
+Specify the HTTP method to use for uploading to the provided --upload-url. Valid
+values are 'auto' (default), 'put', or 'post'. The use of 'auto' will default to
+the method required by the policy-default upload location, if one exists.
+
+This option has no effect on upload methods other than HTTPS.
+.TP
.B \--upload-protocol PROTO
Manually specify the protocol to use for uploading to the target \fBupload-url\fR.
diff --git a/setup.py b/setup.py
index affe731..718ded1 100644
--- a/setup.py
+++ b/setup.py
@@ -71,7 +71,7 @@ setup(name='sos',
('share/man/man1', ['man/en/sosreport.1']),
('share/man/man5', ['man/en/sos.conf.5']),
],
- packages=['sos', 'sos.plugins', 'sos.policies'],
+ packages=['sos', 'sos.plugins', 'sos.policies', 'sos.policies.auth'],
cmdclass={'build': BuildData, 'install_data': InstallData},
requires=['six', 'futures'],
)
diff --git a/Makefile b/Makefile
index ba99c5c..f02b888 100644
--- a/Makefile
+++ b/Makefile
@@ -9,7 +9,7 @@ MINOR := $(shell echo $(VERSION) | cut -f 2 -d '.')
RELEASE := $(shell echo `awk '/^Release:/ {gsub(/\%.*/,""); print $2}' sos.spec`)
REPO = https://github.com/sosreport/sos
-SUBDIRS = po sos sos/plugins sos/policies #docs
+SUBDIRS = po sos sos/plugins sos/policies sos/policies/auth #docs
PYFILES = $(wildcard *.py)
# OS X via brew
# MSGCAT = /usr/local/Cellar/gettext/0.18.1.1/bin/msgcat
diff --git a/sos/policies/auth/Makefile b/sos/policies/auth/Makefile
new file mode 100644
index 0000000000..29a4830406
--- /dev/null
+++ b/sos/policies/auth/Makefile
@@ -0,0 +1,20 @@
+PYTHON=python
+PACKAGE = $(shell basename `pwd`)
+PYFILES = $(wildcard *.py)
+PYVER := $(shell $(PYTHON) -c 'import sys; print("%.3s" %(sys.version))')
+PYSYSDIR := $(shell $(PYTHON) -c 'import sys; print(sys.prefix)')
+PYLIBDIR = $(PYSYSDIR)/lib/python$(PYVER)
+PKGDIR = $(PYLIBDIR)/site-packages/sos/policies/$(PACKAGE)
+
+all:
+ echo "nada"
+
+clean:
+ rm -f *.pyc *.pyo *~
+
+install:
+ mkdir -p $(DESTDIR)/$(PKGDIR)
+ for p in $(PYFILES) ; do \
+ install -m 644 $$p $(DESTDIR)/$(PKGDIR)/$$p; \
+ done
+ $(PYTHON) -c "import compileall; compileall.compile_dir('$(DESTDIR)/$(PKGDIR)', 1, '$(PYDIR)', 1)"
+