• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

xapi-project / xen-api / 12583462520

02 Jan 2025 01:51PM CUT coverage: 78.273%. Remained the same
12583462520

Pull #6206

github

web-flow
Merge 75f0b41f9 into 5f6b50041
Pull Request #6206: xenopsd: Avoid calling to_string every time

3462 of 4423 relevant lines covered (78.27%)

0.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.87
/python3/plugins/extauth-hook-AD.py
1
#!/usr/bin/env python3
2
#
3
# extauth-hook-AD.py
4
#
5
# This plugin manages the following configuration files for external authentication
6
# - /etc/nsswitch.conf
7
# - /etc/pam.d/sshd
8
# - /etc/pam.d/hcp_users
9
# - /etc/ssh/ssh_config
10
#
11
# This module can be called directly as a plugin.  It handles
12
# Active Directory being enabled or disabled as the hosts external_auth_type,
13
# or subjects being added or removed while AD is the external_auth_type,
14
# or xapi starting or stopping while AD is the external_auth_type.
15
#
16
# Alternatively, the extauth-hook module can be called, which will
17
# dispatch to the correct extauth-hook-<type>.py module automatically.
18
import abc
1✔
19
import subprocess
1✔
20
import os
1✔
21
import shutil
1✔
22
import tempfile
1✔
23
import logging
1✔
24
import logging.handlers
1✔
25
from collections import OrderedDict
1✔
26

27
import XenAPIPlugin
1✔
28

29

30
# pylint: disable=too-few-public-methods
31

32

33
HCP_USERS = "/etc/security/hcp_ad_users.conf"
1✔
34
HCP_GROUPS = "/etc/security/hcp_ad_groups.conf"
1✔
35

36

37
def setup_logger():
1✔
38
    """Helper function setup logger"""
39
    addr = "/dev/log"
1✔
40

41
    logging.basicConfig(
1✔
42
        format='%(asctime)s %(levelname)s %(name)s %(funcName)s %(message)s', level=logging.DEBUG)
43
    log = logging.getLogger()
1✔
44

45
    if not os.path.exists(addr):
1✔
46
        log.warning("%s not available, logs are not redirected", addr)
×
47
        return
×
48

49
    # Send to syslog local5, which will be redirected to xapi log /var/log/xensource.log
50
    handler = logging.handlers.SysLogHandler(
1✔
51
        facility='local5', address=addr)
52
    # Send to authpriv, which will be redirected to /var/log/secure
53
    auth_log = logging.handlers.SysLogHandler(
1✔
54
        facility="authpriv", address=addr)
55
    log.addHandler(handler)
1✔
56
    log.addHandler(auth_log)
1✔
57

58

59
setup_logger()
1✔
60
logger = logging.getLogger(__name__)
1✔
61

62

63
def run_cmd(command: "list[str]"):
1✔
64
    """Helper function to run a command and log the output"""
65
    try:
1✔
66
        output = subprocess.check_output(command, universal_newlines=True)
1✔
67
        logger.debug("%s -> %s", command, output.strip())
1✔
68

69
    except OSError:
1✔
70
        logger.exception("Failed to run command %s", command)
1✔
71

72

73
class ADConfig(abc.ABC):
1✔
74
    """Base class for AD configuration"""
75

76
    def __init__(self, path, session, args, ad_enabled=True, load_existing=True, file_mode=0o644):
1✔
77
        self._file_path = path
1✔
78
        self._session = session
1✔
79
        self._args = args
1✔
80
        self._lines = []
1✔
81
        self._ad_enabled = ad_enabled
1✔
82
        self._file_mode = file_mode
1✔
83
        if load_existing and os.path.exists(self._file_path):
1✔
84
            with open(self._file_path, "r", encoding="utf-8") as file:
1✔
85
                lines = file.readlines()
1✔
86
                self._lines = [l.strip() for l in lines]
1✔
87

88

89
    @abc.abstractmethod
1✔
90
    def _apply_to_cache(self): ...
1✔
91

92
    def apply(self):
1✔
93
        """Apply configuration"""
94
        self._apply_to_cache()
1✔
95
        self._install()
1✔
96

97
    def _install(self):
1✔
98
        """Install configuration"""
99
        with tempfile.NamedTemporaryFile(prefix="extauth-", delete=False) as file:
1✔
100
            file.write("\n".join(self._lines).encode("utf-8"))
1✔
101
            file.flush()
1✔
102
            shutil.move(file.name, self._file_path)
1✔
103
            os.chmod(self._file_path, self._file_mode)
1✔
104

105

106
class StaticSSHPam(ADConfig):
1✔
107
    """
108
    Class to manage ssh pam configuration
109
    """
110
    ad_pam_format = """#%PAM-1.0
111
auth        required      pam_env.so
112
auth        sufficient    pam_unix.so try_first_pass nullok
113
auth        sufficient    {ad_module} try_first_pass try_authtok
114
auth        required      pam_deny.so
115

116
session     optional      pam_keyinit.so force revoke
117
session     required      pam_limits.so
118
session     [success=1 default=ignore] pam_succeed_if.so service in crond quiet use_uid
119
session     required      pam_unix.so
120
session     required      pam_loginuid.so
121
session     sufficient    {ad_module}
122

123
account     required      pam_nologin.so
124
account     required      {ad_module} unknown_ok
125
account     sufficient    pam_listfile.so item=user file={user_list} sense=allow onerr=fail
126
account     sufficient    pam_listfile.so item=group file={group_list} sense=allow onerr=fail
127
account     required      pam_unix.so"""
128

129
    no_ad_pam = """#%PAM-1.0
130
auth       include      system-auth
131
account    required     pam_nologin.so
132
account    include      system-auth
133
password   include      system-auth
134
session    optional     pam_keyinit.so force revoke
135
session    include      system-auth
136
session    required     pam_loginuid.so"""
137

138
    def __init__(self, session, args, ad_enabled=True):
1✔
139
        super(StaticSSHPam, self).__init__("/etc/pam.d/sshd", session, args, ad_enabled,
1✔
140
                                           load_existing=False)
141

142
    def _apply_to_cache(self):
1✔
143
        if self._ad_enabled:
1✔
144
            content = self.ad_pam_format.format(ad_module="pam_winbind.so",
1✔
145
                                                user_list=HCP_USERS, group_list=HCP_GROUPS)
146
        else:
147
            content = self.no_ad_pam
1✔
148
        self._lines = content.split("\n")
1✔
149

150

151
class DynamicPam(ADConfig):
1✔
152
    """Base class to manage AD users and groups configure which permit pool admin ssh"""
153

154
    def __init__(self, path, session, args, ad_enabled=True):
1✔
155
        super(DynamicPam, self).__init__(path, session, args, ad_enabled,
1✔
156
                                         load_existing=False)
157
        self.admin_role = self._session.xenapi.role.get_by_name_label(
1✔
158
            'pool-admin')[0]
159

160
    def _apply_to_cache(self):
1✔
161
        # AD is not enabled, just return as the configure file will be removed during install
162
        if not self._ad_enabled:
1✔
163
            return
1✔
164

165
        try:
1✔
166
            # Rewrite the PAM SSH config using the latest info from Active Directory
167
            # and the list of subjects from xapi
168
            subjects = self._session.xenapi.subject.get_all()
1✔
169
            # Add each subject which contains the admin role
170
            for opaque_ref in subjects:
1✔
171
                subject_rec = self._session.xenapi.subject.get_record(
1✔
172
                    opaque_ref)
173
                if self._is_pool_admin(subject_rec) and self._is_responsible_for(subject_rec):
1✔
174
                    self._add_subject(subject_rec)
1✔
175
            self._lines.append("")  # ending new line
1✔
176
        except Exception as exp:  # pylint: disable=broad-except
×
177
            logger.info("Failed to add subjects %s", str(exp))
×
178

179
    def _is_pool_admin(self, subject_rec):
1✔
180
        try:
1✔
181
            return self.admin_role in subject_rec['roles']
1✔
182
        except KeyError:
×
183
            logger.warning("subject %s does not have role", subject_rec)
×
184
            return False
×
185

186

187
    def _is_responsible_for(self, subject_rec):
1✔
188
        try:
1✔
189
            return self._match_subject(subject_rec)
1✔
190
        except KeyError:
×
191
            logger.exception("Failed to match subject %s", subject_rec)
×
192
            return False
×
193

194
    @abc.abstractmethod
1✔
195
    def _match_subject(self, subject_rec): ...
1✔
196

197
    @abc.abstractmethod
1✔
198
    def _add_subject(self, subject_rec): ...
1✔
199

200
    def _install(self):
1✔
201
        if self._ad_enabled:
1✔
202
            super(DynamicPam, self)._install()
1✔
203
        elif os.path.exists(self._file_path):
1✔
204
            os.remove(self._file_path)
1✔
205

206

207
class UsersList(DynamicPam):
1✔
208
    """Class manage users which permit pool admin ssh"""
209

210
    def __init__(self, session, arg, ad_enabled=True):
1✔
211
        super(UsersList, self).__init__(HCP_USERS, session, arg, ad_enabled)
1✔
212

213
    def _match_subject(self, subject_rec):
1✔
214
        return subject_rec["other_config"]["subject-is-group"] != "true"
1✔
215

216
    def _add_upn(self, subject_rec):
1✔
217
        sep = "@"
1✔
218
        upn = ""
1✔
219
        try:
1✔
220
            upn = subject_rec["other_config"]["subject-upn"]
1✔
221
            user, domain = upn.split(sep)
1✔
222
            self._lines.append("{}{}{}".format(user, sep, domain))
1✔
223
        except KeyError:
×
224
            logger.info("subject does not have upn %s", subject_rec)
×
225
        except ValueError:
×
226
            logger.info("UPN format is not right %s", upn)
×
227

228
    def _add_subject(self, subject_rec):
1✔
229
        try:
1✔
230
            sid = subject_rec['subject_identifier']
1✔
231
            formatted_name = subject_rec["other_config"]["subject-name"]
1✔
232
            logger.debug("Permit user %s, Current sid is %s",
1✔
233
                         formatted_name, sid)
234
            self._lines.append(formatted_name)
1✔
235
            # If the ssh key is permitted in the authorized_keys file,
236
            # The original name is compared, add UPN and original name
237
            self._add_upn(subject_rec)
1✔
238
        # pylint: disable=broad-except
239
        except Exception as exp:
×
240
            logger.warning("Failed to add user %s: %s", subject_rec, str(exp))
×
241

242

243
class GroupsList(DynamicPam):
1✔
244
    """Class manage groups which permit pool admin ssh"""
245

246
    def __init__(self, session, arg, ad_enabled=True):
1✔
247
        super(GroupsList, self).__init__(HCP_GROUPS, session, arg, ad_enabled)
1✔
248

249
    def _match_subject(self, subject_rec):
1✔
250
        return subject_rec["other_config"]["subject-is-group"] == "true"
1✔
251

252
    def _add_subject(self, subject_rec):
1✔
253
        try:
1✔
254
            sid = subject_rec['subject_identifier']
1✔
255
            name = subject_rec["other_config"]["subject-name"]
1✔
256
            logger.debug("Permit group %s, Current sid is %s", name, sid)
1✔
257
            self._lines.append(name)
1✔
258
       # pylint: disable=broad-except
259
        except Exception as exp:
×
260
            logger.warning("Failed to add group %s:%s", subject_rec, str(exp))
×
261

262

263
class KeyValueConfig(ADConfig):
1✔
264
    """
265
     Only support configure files with key value in each line, separated by sep
266
     Otherwise, it will be just copied and un-configurable
267
     If multiple lines with the same key exists, only the first line will be configured
268
    """
269
    # Presume normal config does not have such keys
270
    _special_line_prefix = "__key_value_config_sp_line_prefix_"
1✔
271
    _empty_value = ""
1✔
272

273
    def __init__(self, path, session, args, ad_enabled=True, load_existing=True,
1✔
274
                 file_mode=0o644, sep=": ", comment="#"):
275
        super(KeyValueConfig, self).__init__(path, session,
1✔
276
                                             args, ad_enabled, load_existing, file_mode)
277
        self._sep = None if sep.isspace() else sep  # Ignore number/type of spaces
1✔
278
        self._comment = comment
1✔
279
        self._values = OrderedDict()
1✔
280
        self._load_values()
1✔
281

282
    def _is_comment_line(self, line):
1✔
283
        return line.startswith(self._comment)
1✔
284

285
    def _is_special_line(self, line):
1✔
286
        """Whether the line is a special line"""
287
        return line.startswith(self._special_line_prefix)
1✔
288

289
    def _load_values(self):
1✔
290
        for idx, line in enumerate(self._lines):
1✔
291
            # Generate a unique key to store multiple special lines
292
            sp_key = "{}{}".format(self._special_line_prefix, str(idx))
1✔
293
            if line == "":  # Empty line
1✔
294
                self._values[sp_key] = self._empty_value
1✔
295
            elif self._is_comment_line(line):
1✔
296
                self._values[sp_key] = line
1✔
297
            else:  # Parse the key, value pair
298
                item = line.split(self._sep)
1✔
299
                if len(item) != 2:
1✔
300
                    # Taken as raw line
301
                    self._values[sp_key] = line
×
302
                else:
303
                    key, value = item[0].strip(), item[1].strip()
1✔
304
                    if key not in self._values:
1✔
305
                        self._values[key] = value
1✔
306
                    else:
307
                        # Key already exists, Not supported as configurable
308
                        self._values[sp_key] = line
×
309

310
    def _update_key_value(self, key, value):
1✔
311
        self._values[key] = value
1✔
312

313
    def _apply_value(self, key, value):
1✔
314
        if self._is_special_line(key):
1✔
315
            line = value
1✔
316
        else:  # normal line, construct the key value pair
317
            sep = self._sep or " "
1✔
318
            line = "{}{}{}".format(key, sep, value)
1✔
319
        self._lines.append(line)
1✔
320

321
    def _apply_to_cache(self):
1✔
322
        self._lines = []
1✔
323
        for key, value in self._values.items():
1✔
324
            self._apply_value(key, value)
1✔
325

326

327
class NssConfig(KeyValueConfig):
1✔
328
    """Class to manage NSS configuration"""
329

330
    def __init__(self, session, args, ad_enabled=True):
1✔
331
        super(NssConfig, self).__init__(
1✔
332
            "/etc/nsswitch.conf", session, args, ad_enabled)
333
        modules = "files sss"
1✔
334
        if ad_enabled:
1✔
335
            modules = "files hcp winbind"
1✔
336
        self._update_key_value("passwd", modules)
337
        self._update_key_value("group", modules)
1✔
338
        self._update_key_value("shadow", modules)
1✔
339

340

341
class SshdConfig(KeyValueConfig):
1✔
342
    """Class to manage sshd configuration"""
343

344
    def __init__(self, session, args, ad_enabled=True):
1✔
345
        super(SshdConfig, self).__init__("/etc/ssh/sshd_config", session, args, ad_enabled,
1✔
346
                                         sep=" ")
347
        value = "yes" if ad_enabled else "no"
1✔
348
        self._update_key_value("ChallengeResponseAuthentication", value)
1✔
349
        self._update_key_value("GSSAPIAuthentication", value)
1✔
350
        self._update_key_value("GSSAPICleanupCredentials", value)
1✔
351

352
    def apply(self):
1✔
353
        super(SshdConfig, self).apply()
1✔
354
        run_cmd(["/usr/bin/systemctl", "reload-or-restart", "sshd"])
1✔
355

356

357
class PamWinbindConfig(KeyValueConfig):
1✔
358
    """Class to manage winbind pam configuration"""
359

360
    def __init__(self, session, args, ad_enabled=True):
1✔
361
        super(PamWinbindConfig, self).__init__("/etc/security/pam_winbind.conf", session, args,
×
362
                                               ad_enabled, sep=" = ")
363
        self._update_key_value("krb5_auth", "yes")
×
364

365

366
class ConfigManager:
1✔
367
    """Class to manage all the AD configurations"""
368

369
    def __init__(self, session, args, ad_enabled=True):
1✔
370
        self._build_config(session, args, ad_enabled)
×
371

372
    def _build_config(self, session, args, ad_enabled):
1✔
373
        self._nss = NssConfig(session, args, ad_enabled)
×
374
        self._sshd = SshdConfig(session, args, ad_enabled)
×
375
        self._static_pam = StaticSSHPam(session, args, ad_enabled)
×
376
        self._users = UsersList(session, args, ad_enabled)
×
377
        self._groups = GroupsList(session, args, ad_enabled)
×
378
        self._pam_winbind = PamWinbindConfig(session, args, ad_enabled)
×
379

380
    def refresh_all(self):
1✔
381
        """Update all the configurations"""
382
        self._nss.apply()
×
383
        self._sshd.apply()
×
384
        self._users.apply()
×
385
        self._groups.apply()
×
386
        self._static_pam.apply()
×
387
        self._pam_winbind.apply()
×
388

389
    def refresh_dynamic_pam(self):
1✔
390
        """Only refresh the dynanic configurations"""
391
        self._users.apply()
×
392
        self._groups.apply()
×
393

394

395
def refresh_all_configurations(session, args, name, ad_enabled=True):
1✔
396
    """Update all configurations"""
397
    try:
×
398
        logger.info("refresh_all_configurations for %s", name)
×
399
        ConfigManager(session, args, ad_enabled).refresh_all()
×
400
        return str(True)
×
401
    except Exception:  # pylint: disable=broad-except
402
        msg = "Failed to refresh all configurations"
403
        logger.exception(msg)
404
        return msg
405

406

407
def refresh_dynamic_pam(session, args, name):
1✔
408
    """Refresh dynamic pam configurations"""
409
    try:
×
410
        logger.info("refresh_dynamic_pam for %s", name)
×
411
        ConfigManager(session, args).refresh_dynamic_pam()
×
412
        return str(True)
×
413
    except Exception:  # pylint: disable=broad-except
414
        msg = "Failed to refresh dynamic pam configuration"
415
        logger.exception(msg)
416
        return msg
417

418

419
def after_extauth_enable(session, args):
1✔
420
    """Callback for after enable external auth"""
421
    return refresh_all_configurations(session, args, "after_extauth_enable")
×
422

423

424
def after_xapi_initialize(session, args):
1✔
425
    """Callback after xapi initialization"""
426
    return refresh_all_configurations(session, args, "after_xapi_initialize")
×
427

428

429
def after_subject_add(session, args):
1✔
430
    """Callback after add subject"""
431
    return refresh_dynamic_pam(session, args, "after_subject_add")
×
432

433

434
def after_subject_remove(session, args):
1✔
435
    """Callback after remove subject"""
436
    return refresh_dynamic_pam(session, args, "after_subject_remove")
×
437

438

439
def after_subject_update(session, args):
1✔
440
    """Callback after subject update"""
441
    return refresh_dynamic_pam(session, args, "after_subject_update")
×
442

443

444
def after_roles_update(session, args):
1✔
445
    """Callback after roles update"""
446
    return refresh_dynamic_pam(session, args, "after_roles_update")
×
447

448

449
def before_extauth_disable(session, args):
1✔
450
    """Callback before disable external auth"""
451
    return refresh_all_configurations(session, args, "before_extauth_disable", False)
×
452

453

454
# The dispatcher
455
if __name__ == "__main__":
1✔
456
    dispatch_tbl = {
×
457
        "after-extauth-enable":   after_extauth_enable,
458
        "after-xapi-initialize":  after_xapi_initialize,
459
        "after-subject-add":      after_subject_add,
460
        "after-subject-update":   after_subject_update,
461
        "after-subject-remove":   after_subject_remove,
462
        "after-roles-update":     after_roles_update,
463
        "before-extauth-disable": before_extauth_disable,
464
    }
465
    XenAPIPlugin.dispatch(dispatch_tbl)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc