From 308a3cd14f3c2a2316b2bd16594b31855425a1b0 Mon Sep 17 00:00:00 2001 From: Gabriel Nagy Date: Mon, 17 Jul 2023 14:20:02 +0300 Subject: [PATCH 1/2] Certificate autoenrollment POC This is a POC that leverages the samba implementation of certificate autoenrollment. It contains a few patches and fixes on top of the samba#master version of the `gp_cert_auto_enroll_ext.py` file, that will ultimately be upstreamed. Autoenrollment is performed via a separate policy manager that runs a helper Python script (`cert-autoenroll`) which communicates with the Windows CEP/CES services through Samba. For better control and to avoid unexpected behavior we vendor the required Samba files, which are confirmed to work on all Ubuntu versions starting with (and including) Jammy (22.04). Samba has its own cache mechanism which stores information concerning the applied GPOs which we are using in order to ensure idempotency. This is a crude implementation in the sense that we let Samba directly parse the freshly downloaded `Registry.pol` files from the DC. This operation is unreliable and prone to races since the cached pol files can be overwritten by subsequent runs of adsys (e.g. another user applies their policies), so the ulimate goal is to perform the GPO parsing process within adsys (using the mmapped files) and defer to Samba for the CA download and certificate request processes. This work will remain unmerged, to reflect its proof-of-concept status, and subsequent work will take place in separate PRs. Fixes UDENG-850 --- debian/rules | 4 + internal/ad/ad.go | 2 +- internal/adsysservice/adsysservice.go | 2 +- internal/consts/consts.go | 3 + internal/policies/certificate/cert-autoenroll | 88 ++ internal/policies/certificate/certificate.go | 135 +++ .../gp/gp_cert_auto_enroll_ext.py | 536 +++++++++++ .../python/vendor_samba/gp/gpclass.py | 884 ++++++++++++++++++ .../python/vendor_samba/gp/util/logging.py | 112 +++ internal/policies/manager.go | 55 +- 10 files changed, 1811 insertions(+), 10 deletions(-) create mode 100644 internal/policies/certificate/cert-autoenroll create mode 100644 internal/policies/certificate/certificate.go create mode 100644 internal/policies/certificate/python/vendor_samba/gp/gp_cert_auto_enroll_ext.py create mode 100644 internal/policies/certificate/python/vendor_samba/gp/gpclass.py create mode 100644 internal/policies/certificate/python/vendor_samba/gp/util/logging.py diff --git a/debian/rules b/debian/rules index 0ca05fc9d..a594512d1 100755 --- a/debian/rules +++ b/debian/rules @@ -56,6 +56,10 @@ override_dh_auto_install: cp -a systemd/*.timer debian/tmp/lib/systemd/system/ cp -a systemd/user/*.service debian/tmp/usr/lib/systemd/user/ + # vendored python modules + mkdir -p debian/tmp/usr/share/adsys + cp -a internal/policies/certificate/python debian/tmp/usr/share/adsys/ + # Separate windows binaries ifeq ($(WINDOWS_BUILD),1) mkdir -p debian/tmp/usr/share/adsys/windows diff --git a/internal/ad/ad.go b/internal/ad/ad.go index 9f55fa18b..bfa9e01fa 100644 --- a/internal/ad/ad.go +++ b/internal/ad/ad.go @@ -477,7 +477,7 @@ func (ad *AD) parseGPOs(ctx context.Context, gpos []gpo, objectClass ObjectClass defer ad.downloadables[name].mu.RUnlock() _ = ad.downloadables[name].testConcurrent - log.Debugf(ctx, "Parsing GPO %q", name) + log.Debugf(ctx, "Parsing GPO %q with ID %q", name, gpoWithRules.ID) // We need to consider the uppercase version of the name as well, // which could occur in some of the default GPOs such as Default diff --git a/internal/adsysservice/adsysservice.go b/internal/adsysservice/adsysservice.go index 34f95f6a3..76926ac4c 100644 --- a/internal/adsysservice/adsysservice.go +++ b/internal/adsysservice/adsysservice.go @@ -282,7 +282,7 @@ func New(ctx context.Context, opts ...option) (s *Service, err error) { if args.systemUnitDir != "" { policyOptions = append(policyOptions, policies.WithSystemUnitDir(args.systemUnitDir)) } - m, err := policies.NewManager(bus, hostname, policyOptions...) + m, err := policies.NewManager(bus, hostname, adBackend, policyOptions...) if err != nil { return nil, err } diff --git a/internal/consts/consts.go b/internal/consts/consts.go index c44f66d56..7ef159066 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -21,6 +21,9 @@ const ( // DefaultCacheDir is the default path for adsys system cache directory. DefaultCacheDir = "/var/cache/adsys" + // DefaultStateDir is the default path for adsys system state directory. + DefaultStateDir = "/var/lib/adsys" + // DefaultRunDir is the default path for adsys run directory. DefaultRunDir = "/run/adsys" diff --git a/internal/policies/certificate/cert-autoenroll b/internal/policies/certificate/cert-autoenroll new file mode 100644 index 000000000..5302285ba --- /dev/null +++ b/internal/policies/certificate/cert-autoenroll @@ -0,0 +1,88 @@ +#!/usr/bin/python3 + +import argparse +import os +import sys +import tempfile + +from collections import namedtuple + +from samba import param +from samba.credentials import MUST_USE_KERBEROS, Credentials + +from vendor_samba.gp.gpclass import get_dc_hostname, get_deleted_gpos_list, GPOStorage +from vendor_samba.gp import gp_cert_auto_enroll_ext as cae + +GPO = namedtuple('GPO', ['file_sys_path', 'name']) + +PRIVATE_DIR = '/var/lib/adsys/private/certs' +TRUST_DIR = '/var/lib/adsys/certs' + +class adsys_cert_auto_enroll(cae.gp_cert_auto_enroll_ext): + # the default implementation overrides the path to the pol file, we don't want that + # also account for the case insensitivity of the filesystem + def parse(self, path): + if os.path.isfile(path): + return self.read(path) + + policy_path = os.path.dirname(os.path.dirname(path)) + for f in os.listdir(policy_path): + if f.lower() == 'machine': + return self.read(os.path.join(policy_path, f, 'Registry.pol')) + + raise Exception('Could not find policy file for %s' % path) + +def smb_config(realm, enable_debug): + config = "[global]\nrealm = %s\n" % realm + if enable_debug: + config += "log level = 10\n" + return config + +def main(): + parser = argparse.ArgumentParser(description='Certificate autoenrollment via Samba') + parser.add_argument('realm', type=str, + help='The realm of the domain, e.g. example.com') + parser.add_argument('gpo_paths', type=str, + help='Comma-separated list of paths to the GPOs to apply. \ + e.g. /var/cache/adsys/sysvol/Policies/{31B2F340-016D-11D2-945F-00C04FB984F9}') + parser.add_argument('--state-dir', type=str, + default='/var/lib/adsys', + help='Directory to store GPO state in.') + parser.add_argument('--debug', action='store_true', + help='Enable samba debug output.') + + args = parser.parse_args() + + gpo_paths = args.gpo_paths.split(',') + state_dir = args.state_dir + + # Create needed directories if they don't exist + for directory in [state_dir, TRUST_DIR, PRIVATE_DIR]: + if not os.path.exists(directory): + os.makedirs(directory) + + gpos = [GPO(gpo, os.path.basename(gpo)) for gpo in gpo_paths] + + c = Credentials() + c.set_kerberos_state(MUST_USE_KERBEROS) + + with tempfile.NamedTemporaryFile(prefix='smb_conf') as smb_conf: + smb_conf.write(smb_config(args.realm, args.debug).encode('utf-8')) + smb_conf.flush() + + lp = param.LoadParm(smb_conf.name) + c.guess(lp) + + store = GPOStorage(os.path.join(state_dir, 'cert_gpo_state.tdb')) + + username = c.get_username() + gp_db = store.get_gplog(username) + deleted_gpos = get_deleted_gpos_list(gp_db, gpos) + ext = adsys_cert_auto_enroll(lp, c, username, store) + ext.process_group_policy(deleted_gpos, gpos, + trust_dir='/var/lib/adsys/certs', + private_dir='/var/lib/adsys/private/certs', + global_trust_dir='/usr/local/share/ca-certificates') + +if __name__ == "__main__": + sys.exit(main()) diff --git a/internal/policies/certificate/certificate.go b/internal/policies/certificate/certificate.go new file mode 100644 index 000000000..c1c7fa088 --- /dev/null +++ b/internal/policies/certificate/certificate.go @@ -0,0 +1,135 @@ +package certificate + +import ( + "bytes" + "context" + _ "embed" // embed cert enroll python script + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/ubuntu/adsys/internal/consts" + log "github.com/ubuntu/adsys/internal/grpc/logstreamer" + "github.com/ubuntu/adsys/internal/i18n" + "github.com/ubuntu/adsys/internal/smbsafe" + "github.com/ubuntu/decorate" +) + +// Manager prevents running multiple gdm update process in parallel while parsing policy in ApplyPolicy. +type Manager struct { + domain string + krb5CacheDir string + stateDir string + sysvolCacheDir string + certEnrollCmd []string + + mu sync.Mutex // Prevents multiple instances of the certificate manager from running in parallel +} + +// CertEnrollCode is the embedded Python script which requests +// Samba to autoenroll for certificates using the given GPOs. +// +//go:embed cert-autoenroll +var CertEnrollCode string + +type options struct { + runDir string + stateDir string + cacheDir string + certEnrollCmd []string +} +type Option func(*options) + +// WithRunDir overrides the default run directory. +func WithRunDir(p string) func(*options) { + return func(a *options) { + a.runDir = p + } +} + +// WithStateDir overrides the default state directory. +func WithStateDir(p string) func(*options) { + return func(a *options) { + a.stateDir = p + } +} + +// WithCacheDir overrides the default cache directory. +func WithCacheDir(p string) func(*options) { + return func(a *options) { + a.cacheDir = p + } +} + +// New returns a new manager for gdm policy handlers. +func New(domain string, opts ...Option) *Manager { + // defaults + args := options{ + runDir: consts.DefaultRunDir, + stateDir: consts.DefaultStateDir, + cacheDir: consts.DefaultCacheDir, + certEnrollCmd: []string{"python3", "-c", CertEnrollCode}, + } + // applied options + for _, o := range opts { + o(&args) + } + + return &Manager{ + domain: domain, + krb5CacheDir: filepath.Join(args.runDir, "krb5cc"), + stateDir: args.stateDir, + sysvolCacheDir: filepath.Join(args.cacheDir, "sysvol"), + certEnrollCmd: args.certEnrollCmd, + } +} + +// ApplyPolicy generates a dconf computer or user policy based on a list of entries. +func (m *Manager) ApplyPolicy(ctx context.Context, objectName string, isComputer, isOnline bool, gpoPaths []string) (err error) { + defer decorate.OnError(&err, i18n.G("can't apply certificate policy")) + + log.Debug(ctx, "ApplyPolicy certificate policy") + + m.mu.Lock() + defer m.mu.Unlock() + + if !isComputer { + log.Debug(ctx, "Certificate policy is only supported for computers, skipping...") + return nil + } + + if !isOnline { + log.Info(ctx, i18n.G("AD backend is offline, skipping certificate policy")) + return nil + } + + args := append([]string{}, m.certEnrollCmd...) + scriptArgs := []string{m.domain, strings.Join(gpoPaths, ","), "--state-dir", m.stateDir} + cmdArgs := append(args, scriptArgs...) + cmdCtx, cancel := context.WithTimeout(ctx, time.Second*10) + defer cancel() + log.Debugf(ctx, "Running cert autoenroll script with arguments: %q", strings.Join(scriptArgs, " ")) + // #nosec G204 - cmdArgs is under our control (python embedded script or mock for tests) + cmd := exec.CommandContext(cmdCtx, cmdArgs[0], cmdArgs[1:]...) + cmd.Env = append(os.Environ(), + fmt.Sprintf("KRB5CCNAME=%s", filepath.Join(m.krb5CacheDir, objectName)), + fmt.Sprintf("PYTHONPATH=%s", "/usr/share/adsys/python"), // TODO: use overridable consts + ) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + smbsafe.WaitExec() + err = cmd.Run() + smbsafe.DoneExec() + if err != nil { + return fmt.Errorf(i18n.G("failed to run certificate autoenrollment script (exited with %d): %v\n%s"), cmd.ProcessState.ExitCode(), err, stderr.String()) + } + log.Infof(ctx, i18n.G("Certificate autoenrollment script ran successfully (exited with %d)\n%s"), cmd.ProcessState.ExitCode(), stdout.String()) + + return nil +} diff --git a/internal/policies/certificate/python/vendor_samba/gp/gp_cert_auto_enroll_ext.py b/internal/policies/certificate/python/vendor_samba/gp/gp_cert_auto_enroll_ext.py new file mode 100644 index 000000000..fe0505ef0 --- /dev/null +++ b/internal/policies/certificate/python/vendor_samba/gp/gp_cert_auto_enroll_ext.py @@ -0,0 +1,536 @@ +# gp_cert_auto_enroll_ext samba group policy +# Copyright (C) David Mulder 2021 +# +# 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 3 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, see . + +import os +import operator +import requests +from vendor_samba.gp.gpclass import gp_pol_ext, gp_applier, GPOSTATE +from samba import Ldb +from ldb import SCOPE_SUBTREE, SCOPE_BASE +from samba.auth import system_session +from vendor_samba.gp.gpclass import get_dc_hostname +import base64 +from shutil import which +from subprocess import Popen, PIPE +import re +import json +from vendor_samba.gp.util.logging import log +import struct +try: + from cryptography.hazmat.primitives.serialization.pkcs7 import \ + load_der_pkcs7_certificates +except ModuleNotFoundError: + def load_der_pkcs7_certificates(x): return [] + log.error('python cryptography missing pkcs7 support. ' + 'Certificate chain parsing will fail') +from cryptography.hazmat.primitives.serialization import Encoding +from cryptography.x509 import load_der_x509_certificate +from cryptography.hazmat.backends import default_backend +from samba.common import get_string + +cert_wrap = b""" +-----BEGIN CERTIFICATE----- +%s +-----END CERTIFICATE-----""" +endpoint_re = '(https|HTTPS)://(?P[a-zA-Z0-9.-]+)/ADPolicyProvider' + \ + '_CEP_(?P[a-zA-Z]+)/service.svc/CEP' + + +def octet_string_to_objectGUID(data): + """Convert an octet string to an objectGUID.""" + return '%s-%s-%s-%s-%s' % ('%02x' % struct.unpack('H', data[8:10])[0], + '%02x%02x' % struct.unpack('>HL', data[10:])) + + +def group_and_sort_end_point_information(end_point_information): + """Group and Sort End Point Information. + + [MS-CAESO] 4.4.5.3.2.3 + In this step autoenrollment processes the end point information by grouping + it by CEP ID and sorting in the order with which it will use the end point + to access the CEP information. + """ + # Create groups of the CertificateEnrollmentPolicyEndPoint instances that + # have the same value of the EndPoint.PolicyID datum. + end_point_groups = {} + for e in end_point_information: + if e['PolicyID'] not in end_point_groups.keys(): + end_point_groups[e['PolicyID']] = [] + end_point_groups[e['PolicyID']].append(e) + + # Sort each group by following these rules: + for end_point_group in end_point_groups.values(): + # Sort the CertificateEnrollmentPolicyEndPoint instances in ascending + # order based on the EndPoint.Cost value. + end_point_group.sort(key=lambda e: e['Cost']) + + # For instances that have the same EndPoint.Cost: + cost_list = [e['Cost'] for e in end_point_group] + costs = set(cost_list) + for cost in costs: + i = cost_list.index(cost) + j = len(cost_list)-operator.indexOf(reversed(cost_list), cost)-1 + if i == j: + continue + + # Sort those that have EndPoint.Authentication equal to Kerberos + # first. Then sort those that have EndPoint.Authentication equal to + # Anonymous. The rest of the CertificateEnrollmentPolicyEndPoint + # instances follow in an arbitrary order. + def sort_auth(e): + # 0x2 - Kerberos + if e['AuthFlags'] == 0x2: + return 0 + # 0x1 - Anonymous + elif e['AuthFlags'] == 0x1: + return 1 + else: + return 2 + end_point_group[i:j+1] = sorted(end_point_group[i:j+1], + key=sort_auth) + return list(end_point_groups.values()) + +def obtain_end_point_information(entries): + """Obtain End Point Information. + + [MS-CAESO] 4.4.5.3.2.2 + In this step autoenrollment initializes the + CertificateEnrollmentPolicyEndPoints table. + """ + end_point_information = {} + section = 'Software\\Policies\\Microsoft\\Cryptography\\PolicyServers\\' + for e in entries: + if not e.keyname.startswith(section): + continue + name = e.keyname.replace(section, '') + if name not in end_point_information.keys(): + end_point_information[name] = {} + end_point_information[name][e.valuename] = e.data + for ca in end_point_information.values(): + m = re.match(endpoint_re, ca['URL']) + if m: + name = '%s-CA' % m.group('server').replace('.', '-') + ca['name'] = name + ca['hostname'] = m.group('server') + ca['auth'] = m.group('auth') + elif ca['URL'].lower() != 'ldap:': + edata = { 'endpoint': ca['URL'] } + log.error('Failed to parse the endpoint', edata) + return {} + end_point_information = \ + group_and_sort_end_point_information(end_point_information.values()) + return end_point_information + +def fetch_certification_authorities(ldb): + """Initialize CAs. + + [MS-CAESO] 4.4.5.3.1.2 + """ + result = [] + basedn = ldb.get_default_basedn() + # Autoenrollment MUST do an LDAP search for the CA information + # (pKIEnrollmentService) objects under the following container: + dn = 'CN=Enrollment Services,CN=Public Key Services,CN=Services,CN=Configuration,%s' % basedn + attrs = ['cACertificate', 'cn', 'dNSHostName'] + expr = '(objectClass=pKIEnrollmentService)' + res = ldb.search(dn, SCOPE_SUBTREE, expr, attrs) + if len(res) == 0: + return result + for es in res: + data = { 'name': get_string(es['cn'][0]), + 'hostname': get_string(es['dNSHostName'][0]), + 'cACertificate': get_string(base64.b64encode(es['cACertificate'][0])) + } + result.append(data) + return result + +def fetch_template_attrs(ldb, name, attrs=None): + if attrs is None: + attrs = ['msPKI-Minimal-Key-Size'] + basedn = ldb.get_default_basedn() + dn = 'CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,%s' % basedn + expr = '(cn=%s)' % name + res = ldb.search(dn, SCOPE_SUBTREE, expr, attrs) + if len(res) == 1 and 'msPKI-Minimal-Key-Size' in res[0]: + return dict(res[0]) + else: + return {'msPKI-Minimal-Key-Size': ['2048']} + +def format_root_cert(cert): + return cert_wrap % re.sub(b"(.{64})", b"\\1\n", cert.encode(), 0, re.DOTALL) + +def find_cepces_submit(): + certmonger_dirs = [os.environ.get("PATH"), '/usr/lib/certmonger', + '/usr/libexec/certmonger'] + return which('cepces-submit', path=':'.join(certmonger_dirs)) + +def get_supported_templates(server): + cepces_submit = find_cepces_submit() + if os.path.exists(cepces_submit): + env = os.environ + env['CERTMONGER_OPERATION'] = 'GET-SUPPORTED-TEMPLATES' + p = Popen([cepces_submit, '--server=%s' % server, '--auth=Kerberos'], + env=env, stdout=PIPE, stderr=PIPE) + out, err = p.communicate() + if p.returncode != 0: + data = { 'Error': err.decode() } + log.error('Failed to fetch the list of supported templates.', data) + return out.strip().split() + return [] + + +def getca(ca, url, trust_dir): + """Fetch Certificate Chain from the CA.""" + root_cert = os.path.join(trust_dir, '%s.crt' % ca['name']) + root_certs = [] + + try: + r = requests.get(url=url, params={'operation': 'GetCACert', + 'message': 'CAIdentifier'}) + except requests.exceptions.ConnectionError: + log.warn('Failed to establish a new connection') + r = None + if r is None or r.content == b'' or r.headers['Content-Type'] == 'text/html': + log.warn('Failed to fetch the root certificate chain.') + log.warn('The Network Device Enrollment Service is either not' + + ' installed or not configured.') + if 'cACertificate' in ca: + log.warn('Installing the server certificate only.') + try: + cert = load_der_x509_certificate(ca['cACertificate']) + except TypeError: + cert = load_der_x509_certificate(ca['cACertificate'], + default_backend()) + cert_data = cert.public_bytes(Encoding.PEM) + with open(root_cert, 'wb') as w: + w.write(cert_data) + root_certs.append(root_cert) + return root_certs + + if r.headers['Content-Type'] == 'application/x-x509-ca-cert': + # Older versions of load_der_x509_certificate require a backend param + try: + cert = load_der_x509_certificate(r.content) + except TypeError: + cert = load_der_x509_certificate(r.content, default_backend()) + cert_data = cert.public_bytes(Encoding.PEM) + with open(root_cert, 'wb') as w: + w.write(cert_data) + root_certs.append(root_cert) + elif r.headers['Content-Type'] == 'application/x-x509-ca-ra-cert': + certs = load_der_pkcs7_certificates(r.content) + for i in range(0, len(certs)): + cert = certs[i].public_bytes(Encoding.PEM) + filename, extension = root_cert.rsplit('.', 1) + dest = '%s.%d.%s' % (filename, i, extension) + with open(dest, 'wb') as w: + w.write(cert) + root_certs.append(dest) + else: + log.warn('getca: Wrong (or missing) MIME content type') + + return root_certs + + +def cert_enroll(ca, ldb, trust_dir, private_dir, global_trust_dir, auth='Kerberos'): + """Install the root certificate chain.""" + data = dict({'files': [], 'templates': []}, **ca) + url = 'http://%s/CertSrv/mscep/mscep.dll/pkiclient.exe?' % ca['hostname'] + root_certs = getca(ca, url, trust_dir) + data['files'].extend(root_certs) + for src in root_certs: + # Symlink the certs to global trust dir + dst = os.path.join(global_trust_dir, os.path.basename(src)) + try: + os.symlink(src, dst) + data['files'].append(dst) + except PermissionError: + log.warn('Failed to symlink root certificate to the' + ' admin trust anchors') + except FileNotFoundError: + log.warn('Failed to symlink root certificate to the' + ' admin trust anchors.' + ' The directory was not found', global_trust_dir) + except FileExistsError: + # If we're simply downloading a renewed cert, the symlink + # already exists. Ignore the FileExistsError. Preserve the + # existing symlink in the unapply data. + data['files'].append(dst) + update = which('update-ca-certificates') + if update is not None: + Popen([update]).wait() + # Setup Certificate Auto Enrollment + getcert = which('getcert') + cepces_submit = find_cepces_submit() + if getcert is not None and os.path.exists(cepces_submit): + p = Popen([getcert, 'add-ca', '-c', ca['name'], '-e', + '%s --server=%s --auth=%s' % (cepces_submit, + ca['hostname'], auth)], + stdout=PIPE, stderr=PIPE) + out, err = p.communicate() + log.debug(out.decode()) + if p.returncode != 0: + data = { 'Error': err.decode(), 'CA': ca['name'] } + log.error('Failed to add Certificate Authority', data) + supported_templates = get_supported_templates(ca['hostname']) + for template in supported_templates: + attrs = fetch_template_attrs(ldb, template) + nickname = '%s.%s' % (ca['name'], template.decode()) + keyfile = os.path.join(private_dir, '%s.key' % nickname) + certfile = os.path.join(trust_dir, '%s.crt' % nickname) + p = Popen([getcert, 'request', '-c', ca['name'], + '-T', template.decode(), + '-I', nickname, '-k', keyfile, '-f', certfile, + '-g', attrs['msPKI-Minimal-Key-Size'][0]], + stdout=PIPE, stderr=PIPE) + out, err = p.communicate() + log.debug(out.decode()) + if p.returncode != 0: + data = { 'Error': err.decode(), 'Certificate': nickname } + log.error('Failed to request certificate', data) + data['files'].extend([keyfile, certfile]) + data['templates'].append(nickname) + if update is not None: + Popen([update]).wait() + else: + log.warn('certmonger and cepces must be installed for ' + + 'certificate auto enrollment to work') + return json.dumps(data) + +class gp_cert_auto_enroll_ext(gp_pol_ext, gp_applier): + def __str__(self): + return 'Cryptography\AutoEnrollment' + + def unapply(self, guid, attribute, value): + ca_cn = base64.b64decode(attribute) + data = json.loads(value) + getcert = which('getcert') + if getcert is not None: + Popen([getcert, 'remove-ca', '-c', ca_cn]).wait() + for nickname in data['templates']: + Popen([getcert, 'stop-tracking', '-i', nickname]).wait() + for f in data['files']: + if os.path.exists(f): + if os.path.exists(f): + os.unlink(f) + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, ca, applier_func, *args, **kwargs): + attribute = base64.b64encode(ca['name'].encode()).decode() + # If the policy has changed, unapply, then apply new policy + old_val = self.cache_get_attribute_value(guid, attribute) + old_data = json.loads(old_val) if old_val is not None else {} + templates = ['%s.%s' % (ca['name'], t.decode()) for t in get_supported_templates(ca['hostname'])] + new_data = { 'templates': templates, **ca } + if any((new_data[k] != old_data[k] if k in old_data else False) \ + for k in new_data.keys()) or \ + self.cache_get_apply_state() == GPOSTATE.ENFORCE: + self.unapply(guid, attribute, old_val) + # If policy is already applied, skip application + if old_val is not None and \ + self.cache_get_apply_state() != GPOSTATE.ENFORCE: + return + + # Apply the policy and log the changes + data = applier_func(*args, **kwargs) + self.cache_add_attribute(guid, attribute, data) + + def process_group_policy(self, deleted_gpo_list, changed_gpo_list, + trust_dir=None, private_dir=None, global_trust_dir=None): + if trust_dir is None: + trust_dir = self.lp.cache_path('certs') + if private_dir is None: + private_dir = self.lp.private_path('certs') + if global_trust_dir is None: + global_trust_dir = '/etc/pki/trust/anchors' + if not os.path.exists(trust_dir): + os.mkdir(trust_dir, mode=0o755) + if not os.path.exists(private_dir): + os.mkdir(private_dir, mode=0o700) + + for guid, settings in deleted_gpo_list: + if str(self) in settings: + for ca_cn_enc, data in settings[str(self)].items(): + self.unapply(guid, ca_cn_enc, data) + + for gpo in changed_gpo_list: + if gpo.file_sys_path: + section = 'Software\Policies\Microsoft\Cryptography\AutoEnrollment' + pol_file = 'MACHINE/Registry.pol' + path = os.path.join(gpo.file_sys_path, pol_file) + pol_conf = self.parse(path) + if not pol_conf: + continue + for e in pol_conf.entries: + if e.keyname == section and e.valuename == 'AEPolicy': + # This policy applies as specified in [MS-CAESO] 4.4.5.1 + if e.data & 0x8000: + continue # The policy is disabled + enroll = e.data & 0x1 == 0x1 + manage = e.data & 0x2 == 0x2 + retrive_pending = e.data & 0x4 == 0x4 + if enroll: + ca_names = self.__enroll(gpo.name, + pol_conf.entries, + trust_dir, private_dir, + global_trust_dir) + + # Cleanup any old CAs that have been removed + ca_attrs = [base64.b64encode(n.encode()).decode() \ + for n in ca_names] + self.clean(gpo.name, keep=ca_attrs) + else: + # If enrollment has been disabled for this GPO, + # remove any existing policy + ca_attrs = \ + self.cache_get_all_attribute_values(gpo.name) + self.clean(gpo.name, remove=list(ca_attrs.keys())) + + def __read_cep_data(self, guid, ldb, end_point_information, + trust_dir, private_dir, global_trust_dir): + """Read CEP Data. + + [MS-CAESO] 4.4.5.3.2.4 + In this step autoenrollment initializes instances of the + CertificateEnrollmentPolicy by accessing end points associated with CEP + groups created in the previous step. + """ + # For each group created in the previous step: + for end_point_group in end_point_information: + # Pick an arbitrary instance of the + # CertificateEnrollmentPolicyEndPoint from the group + e = end_point_group[0] + + # If this instance does not have the AutoEnrollmentEnabled flag set + # in the EndPoint.Flags, continue with the next group. + if not e['Flags'] & 0x10: + continue + + # If the current group contains a + # CertificateEnrollmentPolicyEndPoint instance with EndPoint.URI + # equal to "LDAP": + if any([e['URL'] == 'LDAP:' for e in end_point_group]): + # Perform an LDAP search to read the value of the objectGuid + # attribute of the root object of the forest root domain NC. If + # any errors are encountered, continue with the next group. + res = ldb.search('', SCOPE_BASE, '(objectClass=*)', + ['rootDomainNamingContext']) + if len(res) != 1: + continue + res2 = ldb.search(res[0]['rootDomainNamingContext'][0], + SCOPE_BASE, '(objectClass=*)', + ['objectGUID']) + if len(res2) != 1: + continue + + # Compare the value read in the previous step to the + # EndPoint.PolicyId datum CertificateEnrollmentPolicyEndPoint + # instance. If the values do not match, continue with the next + # group. + objectGUID = '{%s}' % \ + octet_string_to_objectGUID(res2[0]['objectGUID'][0]).upper() + if objectGUID != e['PolicyID']: + continue + + # For each CertificateEnrollmentPolicyEndPoint instance for that + # group: + ca_names = [] + for ca in end_point_group: + # If EndPoint.URI equals "LDAP": + if ca['URL'] == 'LDAP:': + # This is a basic configuration. + cas = fetch_certification_authorities(ldb) + for _ca in cas: + self.apply(guid, _ca, cert_enroll, _ca, ldb, trust_dir, + private_dir, global_trust_dir) + ca_names.append(_ca['name']) + # If EndPoint.URI starts with "HTTPS//": + elif ca['URL'].lower().startswith('https://'): + self.apply(guid, ca, cert_enroll, ca, ldb, trust_dir, + private_dir, global_trust_dir, auth=ca['auth']) + ca_names.append(ca['name']) + else: + edata = { 'endpoint': ca['URL'] } + log.error('Unrecognized endpoint', edata) + return ca_names + + def __enroll(self, guid, entries, trust_dir, private_dir, global_trust_dir): + url = 'ldap://%s' % get_dc_hostname(self.creds, self.lp) + ldb = Ldb(url=url, session_info=system_session(), + lp=self.lp, credentials=self.creds) + + ca_names = [] + end_point_information = obtain_end_point_information(entries) + if len(end_point_information) > 0: + ca_names.extend(self.__read_cep_data(guid, ldb, + end_point_information, + trust_dir, private_dir)) + else: + cas = fetch_certification_authorities(ldb) + for ca in cas: + self.apply(guid, ca, cert_enroll, ca, ldb, trust_dir, + private_dir, global_trust_dir) + ca_names.append(ca['name']) + return ca_names + + def rsop(self, gpo): + output = {} + pol_file = 'MACHINE/Registry.pol' + section = 'Software\Policies\Microsoft\Cryptography\AutoEnrollment' + if gpo.file_sys_path: + path = os.path.join(gpo.file_sys_path, pol_file) + pol_conf = self.parse(path) + if not pol_conf: + return output + for e in pol_conf.entries: + if e.keyname == section and e.valuename == 'AEPolicy': + enroll = e.data & 0x1 == 0x1 + if e.data & 0x8000 or not enroll: + continue + output['Auto Enrollment Policy'] = {} + url = 'ldap://%s' % get_dc_hostname(self.creds, self.lp) + ldb = Ldb(url=url, session_info=system_session(), + lp=self.lp, credentials=self.creds) + end_point_information = \ + obtain_end_point_information(pol_conf.entries) + cas = fetch_certification_authorities(ldb) + if len(end_point_information) > 0: + cas2 = [ep for sl in end_point_information for ep in sl] + if any([ca['URL'] == 'LDAP:' for ca in cas2]): + cas.extend(cas2) + else: + cas = cas2 + for ca in cas: + if 'URL' in ca and ca['URL'] == 'LDAP:': + continue + policy = 'Auto Enrollment Policy' + cn = ca['name'] + if policy not in output: + output[policy] = {} + output[policy][cn] = {} + if 'cACertificate' in ca: + output[policy][cn]['CA Certificate'] = \ + format_root_cert(ca['cACertificate']).decode() + output[policy][cn]['Auto Enrollment Server'] = \ + ca['hostname'] + supported_templates = \ + get_supported_templates(ca['hostname']) + output[policy][cn]['Templates'] = \ + [t.decode() for t in supported_templates] + return output diff --git a/internal/policies/certificate/python/vendor_samba/gp/gpclass.py b/internal/policies/certificate/python/vendor_samba/gp/gpclass.py new file mode 100644 index 000000000..0ef86576d --- /dev/null +++ b/internal/policies/certificate/python/vendor_samba/gp/gpclass.py @@ -0,0 +1,884 @@ +# Reads important GPO parameters and updates Samba +# Copyright (C) Luke Morrison 2013 +# +# 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 3 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, see . + + +import sys +import os, shutil +import errno +import tdb +import pwd +sys.path.insert(0, "bin/python") +from samba import NTSTATUSError +from configparser import ConfigParser +from io import StringIO +import traceback +from samba.common import get_bytes +from abc import ABCMeta, abstractmethod +import xml.etree.ElementTree as etree +import re +from samba.net import Net +from samba.dcerpc import nbt +from samba.samba3 import libsmb_samba_internal as libsmb +import samba.gpo as gpo +from samba.param import LoadParm +from uuid import UUID +from tempfile import NamedTemporaryFile +from samba.dcerpc import preg +from samba.dcerpc import misc +from samba.ndr import ndr_pack, ndr_unpack +from samba.credentials import SMB_SIGNING_REQUIRED +from vendor_samba.gp.util.logging import log +from hashlib import blake2b +import numbers +from samba.common import get_string + +try: + from enum import Enum + GPOSTATE = Enum('GPOSTATE', 'APPLY ENFORCE UNAPPLY') +except ImportError: + class GPOSTATE: + APPLY = 1 + ENFORCE = 2 + UNAPPLY = 3 + + +class gp_log: + ''' Log settings overwritten by gpo apply + The gp_log is an xml file that stores a history of gpo changes (and the + original setting value). + + The log is organized like so: + + + + + + + + + -864000000000 + -36288000000000 + 7 + 1 + + + 1d + + 300 + + + + + + Each guid value contains a list of extensions, which contain a list of + attributes. The guid value represents a GPO. The attributes are the values + of those settings prior to the application of the GPO. + The list of guids is enclosed within a user name, which represents the user + the settings were applied to. This user may be the samaccountname of the + local computer, which implies that these are machine policies. + The applylog keeps track of the order in which the GPOs were applied, so + that they can be rolled back in reverse, returning the machine to the state + prior to policy application. + ''' + def __init__(self, user, gpostore, db_log=None): + ''' Initialize the gp_log + param user - the username (or machine name) that policies are + being applied to + param gpostore - the GPOStorage obj which references the tdb which + contains gp_logs + param db_log - (optional) a string to initialize the gp_log + ''' + self._state = GPOSTATE.APPLY + self.gpostore = gpostore + self.username = user + if db_log: + self.gpdb = etree.fromstring(db_log) + else: + self.gpdb = etree.Element('gp') + self.user = user + user_obj = self.gpdb.find('user[@name="%s"]' % user) + if user_obj is None: + user_obj = etree.SubElement(self.gpdb, 'user') + user_obj.attrib['name'] = user + + def state(self, value): + ''' Policy application state + param value - APPLY, ENFORCE, or UNAPPLY + + The behavior of the gp_log depends on whether we are applying policy, + enforcing policy, or unapplying policy. During an apply, old settings + are recorded in the log. During an enforce, settings are being applied + but the gp_log does not change. During an unapply, additions to the log + should be ignored (since function calls to apply settings are actually + reverting policy), but removals from the log are allowed. + ''' + # If we're enforcing, but we've unapplied, apply instead + if value == GPOSTATE.ENFORCE: + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + apply_log = user_obj.find('applylog') + if apply_log is None or len(apply_log) == 0: + self._state = GPOSTATE.APPLY + else: + self._state = value + else: + self._state = value + + def get_state(self): + '''Check the GPOSTATE + ''' + return self._state + + def set_guid(self, guid): + ''' Log to a different GPO guid + param guid - guid value of the GPO from which we're applying + policy + ''' + self.guid = guid + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + obj = user_obj.find('guid[@value="%s"]' % guid) + if obj is None: + obj = etree.SubElement(user_obj, 'guid') + obj.attrib['value'] = guid + if self._state == GPOSTATE.APPLY: + apply_log = user_obj.find('applylog') + if apply_log is None: + apply_log = etree.SubElement(user_obj, 'applylog') + prev = apply_log.find('guid[@value="%s"]' % guid) + if prev is None: + item = etree.SubElement(apply_log, 'guid') + item.attrib['count'] = '%d' % (len(apply_log) - 1) + item.attrib['value'] = guid + + def store(self, gp_ext_name, attribute, old_val): + ''' Store an attribute in the gp_log + param gp_ext_name - Name of the extension applying policy + param attribute - The attribute being modified + param old_val - The value of the attribute prior to policy + application + ''' + if self._state == GPOSTATE.UNAPPLY or self._state == GPOSTATE.ENFORCE: + return None + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is None: + ext = etree.SubElement(guid_obj, 'gp_ext') + ext.attrib['name'] = gp_ext_name + attr = ext.find('attribute[@name="%s"]' % attribute) + if attr is None: + attr = etree.SubElement(ext, 'attribute') + attr.attrib['name'] = attribute + attr.text = old_val + + def retrieve(self, gp_ext_name, attribute): + ''' Retrieve a stored attribute from the gp_log + param gp_ext_name - Name of the extension which applied policy + param attribute - The attribute being retrieved + return - The value of the attribute prior to policy + application + ''' + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is not None: + attr = ext.find('attribute[@name="%s"]' % attribute) + if attr is not None: + return attr.text + return None + + def retrieve_all(self, gp_ext_name): + ''' Retrieve all stored attributes for this user, GPO guid, and CSE + param gp_ext_name - Name of the extension which applied policy + return - The values of the attributes prior to policy + application + ''' + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is not None: + attrs = ext.findall('attribute') + return {attr.attrib['name']: attr.text for attr in attrs} + return {} + + def get_applied_guids(self): + ''' Return a list of applied ext guids + return - List of guids for gpos that have applied settings + to the system. + ''' + guids = [] + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + if user_obj is not None: + apply_log = user_obj.find('applylog') + if apply_log is not None: + guid_objs = apply_log.findall('guid[@count]') + guids_by_count = [(g.get('count'), g.get('value')) + for g in guid_objs] + guids_by_count.sort(reverse=True) + guids.extend(guid for count, guid in guids_by_count) + return guids + + def get_applied_settings(self, guids): + ''' Return a list of applied ext guids + return - List of tuples containing the guid of a gpo, then + a dictionary of policies and their values prior + policy application. These are sorted so that the + most recently applied settings are removed first. + ''' + ret = [] + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + for guid in guids: + guid_settings = user_obj.find('guid[@value="%s"]' % guid) + exts = guid_settings.findall('gp_ext') + settings = {} + for ext in exts: + attr_dict = {} + attrs = ext.findall('attribute') + for attr in attrs: + attr_dict[attr.attrib['name']] = attr.text + settings[ext.attrib['name']] = attr_dict + ret.append((guid, settings)) + return ret + + def delete(self, gp_ext_name, attribute): + ''' Remove an attribute from the gp_log + param gp_ext_name - name of extension from which to remove the + attribute + param attribute - attribute to remove + ''' + user_obj = self.gpdb.find('user[@name="%s"]' % self.user) + guid_obj = user_obj.find('guid[@value="%s"]' % self.guid) + assert guid_obj is not None, "gpo guid was not set" + ext = guid_obj.find('gp_ext[@name="%s"]' % gp_ext_name) + if ext is not None: + attr = ext.find('attribute[@name="%s"]' % attribute) + if attr is not None: + ext.remove(attr) + if len(ext) == 0: + guid_obj.remove(ext) + + def commit(self): + ''' Write gp_log changes to disk ''' + self.gpostore.store(self.username, etree.tostring(self.gpdb, 'utf-8')) + + +class GPOStorage: + def __init__(self, log_file): + if os.path.isfile(log_file): + self.log = tdb.open(log_file) + else: + self.log = tdb.Tdb(log_file, 0, tdb.DEFAULT, os.O_CREAT | os.O_RDWR) + + def start(self): + self.log.transaction_start() + + def get_int(self, key): + try: + return int(self.log.get(get_bytes(key))) + except TypeError: + return None + + def get(self, key): + return self.log.get(get_bytes(key)) + + def get_gplog(self, user): + return gp_log(user, self, self.log.get(get_bytes(user))) + + def store(self, key, val): + self.log.store(get_bytes(key), get_bytes(val)) + + def cancel(self): + self.log.transaction_cancel() + + def delete(self, key): + self.log.delete(get_bytes(key)) + + def commit(self): + self.log.transaction_commit() + + def __del__(self): + self.log.close() + + +class gp_ext(object): + __metaclass__ = ABCMeta + + def __init__(self, lp, creds, username, store): + self.lp = lp + self.creds = creds + self.username = username + self.gp_db = store.get_gplog(username) + + @abstractmethod + def process_group_policy(self, deleted_gpo_list, changed_gpo_list): + pass + + @abstractmethod + def read(self, policy): + pass + + def parse(self, afile): + local_path = self.lp.cache_path('gpo_cache') + data_file = os.path.join(local_path, check_safe_path(afile).upper()) + if os.path.exists(data_file): + return self.read(data_file) + return None + + @abstractmethod + def __str__(self): + pass + + @abstractmethod + def rsop(self, gpo): + return {} + + +class gp_inf_ext(gp_ext): + def read(self, data_file): + policy = open(data_file, 'rb').read() + inf_conf = ConfigParser(interpolation=None) + inf_conf.optionxform = str + try: + inf_conf.readfp(StringIO(policy.decode())) + except UnicodeDecodeError: + inf_conf.readfp(StringIO(policy.decode('utf-16'))) + return inf_conf + + +class gp_pol_ext(gp_ext): + def read(self, data_file): + raw = open(data_file, 'rb').read() + return ndr_unpack(preg.file, raw) + + +class gp_xml_ext(gp_ext): + def read(self, data_file): + raw = open(data_file, 'rb').read() + try: + return etree.fromstring(raw.decode()) + except UnicodeDecodeError: + return etree.fromstring(raw.decode('utf-16')) + + +class gp_applier(object): + '''Group Policy Applier/Unapplier/Modifier + The applier defines functions for monitoring policy application, + removal, and modification. It must be a multi-derived class paired + with a subclass of gp_ext. + ''' + __metaclass__ = ABCMeta + + def cache_add_attribute(self, guid, attribute, value): + '''Add an attribute and value to the Group Policy cache + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being applied + value - The value of the policy being applied + + Normally called by the subclass apply() function after applying policy. + ''' + self.gp_db.set_guid(guid) + self.gp_db.store(str(self), attribute, value) + self.gp_db.commit() + + def cache_remove_attribute(self, guid, attribute): + '''Remove an attribute from the Group Policy cache + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being unapplied + + Normally called by the subclass unapply() function when removing old + policy. + ''' + self.gp_db.set_guid(guid) + self.gp_db.delete(str(self), attribute) + self.gp_db.commit() + + def cache_get_attribute_value(self, guid, attribute): + '''Retrieve the value stored in the cache for the given attribute + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy + ''' + self.gp_db.set_guid(guid) + return self.gp_db.retrieve(str(self), attribute) + + def cache_get_all_attribute_values(self, guid): + '''Retrieve all attribute/values currently stored for this gpo+policy + guid - The GPO guid which applies this policy + ''' + self.gp_db.set_guid(guid) + return self.gp_db.retrieve_all(str(self)) + + def cache_get_apply_state(self): + '''Return the current apply state + return - APPLY|ENFORCE|UNAPPLY + ''' + return self.gp_db.get_state() + + def generate_attribute(self, name, *args): + '''Generate an attribute name from arbitrary data + name - A name to ensure uniqueness + args - Any arbitrary set of args, str or bytes + return - A blake2b digest of the data, the attribute + + The importance here is the digest of the data makes the attribute + reproducible and uniquely identifies it. Hashing the name with + the data ensures we don't falsly identify a match which is the same + text in a different file. Using this attribute generator is optional. + ''' + data = b''.join([get_bytes(arg) for arg in [*args]]) + return blake2b(get_bytes(name)+data).hexdigest() + + def generate_value_hash(self, *args): + '''Generate a unique value which identifies value changes + args - Any arbitrary set of args, str or bytes + return - A blake2b digest of the data, the value represented + ''' + data = b''.join([get_bytes(arg) for arg in [*args]]) + return blake2b(data).hexdigest() + + @abstractmethod + def unapply(self, guid, attribute, value): + '''Group Policy Unapply + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being unapplied + value - The value of the policy being unapplied + ''' + pass + + @abstractmethod + def apply(self, guid, attribute, applier_func, *args): + '''Group Policy Apply + guid - The GPO guid which applies this policy + attribute - The attribute name of the policy being applied + applier_func - An applier function which takes variable args + args - The variable arguments to pass to applier_func + + The applier_func function MUST return the value of the policy being + applied. It's important that implementations of `apply` check for and + first unapply any changed policy. See for example calls to + `cache_get_all_attribute_values()` which searches for all policies + applied by this GPO for this Client Side Extension (CSE). + ''' + pass + + def clean(self, guid, keep=None, remove=None, **kwargs): + '''Cleanup old removed attributes + keep - A list of attributes to keep + remove - A single attribute to remove, or a list of attributes to + remove + kwargs - Additional keyword args required by the subclass unapply + function + + This is only necessary for CSEs which provide multiple attributes. + ''' + # Clean syntax is, either provide a single remove attribute, + # or a list of either removal attributes or keep attributes. + if keep is None: + keep = [] + if remove is None: + remove = [] + + if type(remove) != list: + value = self.cache_get_attribute_value(guid, remove) + if value is not None: + self.unapply(guid, remove, value, **kwargs) + else: + old_vals = self.cache_get_all_attribute_values(guid) + for attribute, value in old_vals.items(): + if (len(remove) > 0 and attribute in remove) or \ + (len(keep) > 0 and attribute not in keep): + self.unapply(guid, attribute, value, **kwargs) + + +class gp_file_applier(gp_applier): + '''Group Policy File Applier/Unapplier/Modifier + Subclass of abstract class gp_applier for monitoring policy applied + via a file. + ''' + + def __generate_value(self, value_hash, files, sep): + data = [value_hash] + data.extend(files) + return sep.join(data) + + def __parse_value(self, value, sep): + '''Parse a value + return - A unique HASH, followed by the file list + ''' + if value is None: + return None, [] + data = value.split(sep) + if '/' in data[0]: + # The first element is not a hash, but a filename. This is a + # legacy value. + return None, data + else: + return data[0], data[1:] if len(data) > 1 else [] + + def unapply(self, guid, attribute, files, sep=':'): + # If the value isn't a list of files, parse value from the log + if type(files) != list: + _, files = self.__parse_value(files, sep) + for file in files: + if os.path.exists(file): + os.unlink(file) + self.cache_remove_attribute(guid, attribute) + + def apply(self, guid, attribute, value_hash, applier_func, *args, sep=':'): + ''' + applier_func MUST return a list of files created by the applier. + + This applier is for policies which only apply to a single file (with + a couple small exceptions). This applier will remove any policy applied + by this GPO which doesn't match the new policy. + ''' + # If the policy has changed, unapply, then apply new policy + old_val = self.cache_get_attribute_value(guid, attribute) + # Ignore removal if this policy is applied and hasn't changed + old_val_hash, old_val_files = self.__parse_value(old_val, sep) + if (old_val_hash != value_hash or \ + self.cache_get_apply_state() == GPOSTATE.ENFORCE) or \ + not all([os.path.exists(f) for f in old_val_files]): + self.unapply(guid, attribute, old_val_files) + else: + # If policy is already applied, skip application + return + + # Apply the policy and log the changes + files = applier_func(*args) + new_value = self.__generate_value(value_hash, files, sep) + self.cache_add_attribute(guid, attribute, new_value) + + +''' Fetch the hostname of a writable DC ''' + + +def get_dc_hostname(creds, lp): + net = Net(creds=creds, lp=lp) + cldap_ret = net.finddc(domain=lp.get('realm'), flags=(nbt.NBT_SERVER_LDAP | + nbt.NBT_SERVER_DS)) + return cldap_ret.pdc_dns_name + + +''' Fetch a list of GUIDs for applicable GPOs ''' + + +def get_gpo_list(dc_hostname, creds, lp, username): + gpos = [] + ads = gpo.ADS_STRUCT(dc_hostname, lp, creds) + if ads.connect(): + # username is DOM\\SAM, but get_gpo_list expects SAM + gpos = ads.get_gpo_list(username.split('\\')[-1]) + return gpos + + +def cache_gpo_dir(conn, cache, sub_dir): + loc_sub_dir = sub_dir.upper() + local_dir = os.path.join(cache, loc_sub_dir) + try: + os.makedirs(local_dir, mode=0o755) + except OSError as e: + if e.errno != errno.EEXIST: + raise + for fdata in conn.list(sub_dir): + if fdata['attrib'] & libsmb.FILE_ATTRIBUTE_DIRECTORY: + cache_gpo_dir(conn, cache, os.path.join(sub_dir, fdata['name'])) + else: + local_name = fdata['name'].upper() + f = NamedTemporaryFile(delete=False, dir=local_dir) + fname = os.path.join(sub_dir, fdata['name']).replace('/', '\\') + f.write(conn.loadfile(fname)) + f.close() + os.rename(f.name, os.path.join(local_dir, local_name)) + + +def check_safe_path(path): + dirs = re.split('/|\\\\', path) + if 'sysvol' in path.lower(): + ldirs = re.split('/|\\\\', path.lower()) + dirs = dirs[ldirs.index('sysvol') + 1:] + if '..' not in dirs: + return os.path.join(*dirs) + raise OSError(path) + + +def check_refresh_gpo_list(dc_hostname, lp, creds, gpos): + # Force signing for the connection + saved_signing_state = creds.get_smb_signing() + creds.set_smb_signing(SMB_SIGNING_REQUIRED) + conn = libsmb.Conn(dc_hostname, 'sysvol', lp=lp, creds=creds) + # Reset signing state + creds.set_smb_signing(saved_signing_state) + cache_path = lp.cache_path('gpo_cache') + for gpo_obj in gpos: + if not gpo_obj.file_sys_path: + continue + cache_gpo_dir(conn, cache_path, check_safe_path(gpo_obj.file_sys_path)) + + +def get_deleted_gpos_list(gp_db, gpos): + applied_gpos = gp_db.get_applied_guids() + current_guids = set([p.name for p in gpos]) + deleted_gpos = [guid for guid in applied_gpos if guid not in current_guids] + return gp_db.get_applied_settings(deleted_gpos) + +def gpo_version(lp, path): + # gpo.gpo_get_sysvol_gpt_version() reads the GPT.INI from a local file, + # read from the gpo client cache. + gpt_path = lp.cache_path(os.path.join('gpo_cache', path)) + return int(gpo.gpo_get_sysvol_gpt_version(gpt_path)[1]) + + +def apply_gp(lp, creds, store, gp_extensions, username, target, force=False): + gp_db = store.get_gplog(username) + dc_hostname = get_dc_hostname(creds, lp) + gpos = get_gpo_list(dc_hostname, creds, lp, username) + del_gpos = get_deleted_gpos_list(gp_db, gpos) + try: + check_refresh_gpo_list(dc_hostname, lp, creds, gpos) + except: + log.error('Failed downloading gpt cache from \'%s\' using SMB' + % dc_hostname) + return + + if force: + changed_gpos = gpos + gp_db.state(GPOSTATE.ENFORCE) + else: + changed_gpos = [] + for gpo_obj in gpos: + if not gpo_obj.file_sys_path: + continue + guid = gpo_obj.name + path = check_safe_path(gpo_obj.file_sys_path).upper() + version = gpo_version(lp, path) + if version != store.get_int(guid): + log.info('GPO %s has changed' % guid) + changed_gpos.append(gpo_obj) + gp_db.state(GPOSTATE.APPLY) + + store.start() + for ext in gp_extensions: + try: + ext = ext(lp, creds, username, store) + if target == 'Computer': + ext.process_group_policy(del_gpos, changed_gpos) + else: + drop_privileges(creds.get_principal(), ext.process_group_policy, + del_gpos, changed_gpos) + except Exception as e: + log.error('Failed to apply extension %s' % str(ext)) + _, _, tb = sys.exc_info() + filename, line_number, _, _ = traceback.extract_tb(tb)[-1] + log.error('%s:%d: %s: %s' % (filename, line_number, + type(e).__name__, str(e))) + continue + for gpo_obj in gpos: + if not gpo_obj.file_sys_path: + continue + guid = gpo_obj.name + path = check_safe_path(gpo_obj.file_sys_path).upper() + version = gpo_version(lp, path) + store.store(guid, '%i' % version) + store.commit() + + +def unapply_gp(lp, creds, store, gp_extensions, username, target): + gp_db = store.get_gplog(username) + gp_db.state(GPOSTATE.UNAPPLY) + # Treat all applied gpos as deleted + del_gpos = gp_db.get_applied_settings(gp_db.get_applied_guids()) + store.start() + for ext in gp_extensions: + try: + ext = ext(lp, creds, username, store) + if target == 'Computer': + ext.process_group_policy(del_gpos, []) + else: + drop_privileges(username, ext.process_group_policy, + del_gpos, []) + except Exception as e: + log.error('Failed to unapply extension %s' % str(ext)) + log.error('Message was: ' + str(e)) + continue + store.commit() + + +def __rsop_vals(vals, level=4): + if type(vals) == dict: + ret = [' '*level + '[ %s ] = %s' % (k, __rsop_vals(v, level+2)) + for k, v in vals.items()] + return '\n' + '\n'.join(ret) + elif type(vals) == list: + ret = [' '*level + '[ %s ]' % __rsop_vals(v, level+2) for v in vals] + return '\n' + '\n'.join(ret) + else: + if isinstance(vals, numbers.Number): + return ' '*(level+2) + str(vals) + else: + return ' '*(level+2) + get_string(vals) + +def rsop(lp, creds, store, gp_extensions, username, target): + dc_hostname = get_dc_hostname(creds, lp) + gpos = get_gpo_list(dc_hostname, creds, lp, username) + check_refresh_gpo_list(dc_hostname, lp, creds, gpos) + + print('Resultant Set of Policy') + print('%s Policy\n' % target) + term_width = shutil.get_terminal_size(fallback=(120, 50))[0] + for gpo_obj in gpos: + if gpo_obj.display_name.strip() == 'Local Policy': + continue # We never apply local policy + print('GPO: %s' % gpo_obj.display_name) + print('='*term_width) + for ext in gp_extensions: + ext = ext(lp, creds, username, store) + cse_name_m = re.findall(r"'([\w\.]+)'", str(type(ext))) + if len(cse_name_m) > 0: + cse_name = cse_name_m[-1].split('.')[-1] + else: + cse_name = ext.__module__.split('.')[-1] + print(' CSE: %s' % cse_name) + print(' ' + ('-'*int(term_width/2))) + for section, settings in ext.rsop(gpo_obj).items(): + print(' Policy Type: %s' % section) + print(' ' + ('-'*int(term_width/2))) + print(__rsop_vals(settings).lstrip('\n')) + print(' ' + ('-'*int(term_width/2))) + print(' ' + ('-'*int(term_width/2))) + print('%s\n' % ('='*term_width)) + + +def parse_gpext_conf(smb_conf): + from samba.samba3 import param as s3param + lp = s3param.get_context() + if smb_conf is not None: + lp.load(smb_conf) + else: + lp.load_default() + ext_conf = lp.state_path('gpext.conf') + parser = ConfigParser(interpolation=None) + parser.read(ext_conf) + return lp, parser + + +def atomic_write_conf(lp, parser): + ext_conf = lp.state_path('gpext.conf') + with NamedTemporaryFile(mode="w+", delete=False, dir=os.path.dirname(ext_conf)) as f: + parser.write(f) + os.rename(f.name, ext_conf) + + +def check_guid(guid): + # Check for valid guid with curly braces + if guid[0] != '{' or guid[-1] != '}' or len(guid) != 38: + return False + try: + UUID(guid, version=4) + except ValueError: + return False + return True + + +def register_gp_extension(guid, name, path, + smb_conf=None, machine=True, user=True): + # Check that the module exists + if not os.path.exists(path): + return False + if not check_guid(guid): + return False + + lp, parser = parse_gpext_conf(smb_conf) + if guid not in parser.sections(): + parser.add_section(guid) + parser.set(guid, 'DllName', path) + parser.set(guid, 'ProcessGroupPolicy', name) + parser.set(guid, 'NoMachinePolicy', "0" if machine else "1") + parser.set(guid, 'NoUserPolicy', "0" if user else "1") + + atomic_write_conf(lp, parser) + + return True + + +def list_gp_extensions(smb_conf=None): + _, parser = parse_gpext_conf(smb_conf) + results = {} + for guid in parser.sections(): + results[guid] = {} + results[guid]['DllName'] = parser.get(guid, 'DllName') + results[guid]['ProcessGroupPolicy'] = \ + parser.get(guid, 'ProcessGroupPolicy') + results[guid]['MachinePolicy'] = \ + not int(parser.get(guid, 'NoMachinePolicy')) + results[guid]['UserPolicy'] = not int(parser.get(guid, 'NoUserPolicy')) + return results + + +def unregister_gp_extension(guid, smb_conf=None): + if not check_guid(guid): + return False + + lp, parser = parse_gpext_conf(smb_conf) + if guid in parser.sections(): + parser.remove_section(guid) + + atomic_write_conf(lp, parser) + + return True + + +def set_privileges(username, uid, gid): + ''' + Set current process privileges + ''' + + os.setegid(gid) + os.seteuid(uid) + + +def drop_privileges(username, func, *args): + ''' + Run supplied function with privileges for specified username. + ''' + current_uid = os.getuid() + + if not current_uid == 0: + raise Exception('Not enough permissions to drop privileges') + + user_uid = pwd.getpwnam(username).pw_uid + user_gid = pwd.getpwnam(username).pw_gid + + # Drop privileges + set_privileges(username, user_uid, user_gid) + + # We need to catch exception in order to be able to restore + # privileges later in this function + out = None + exc = None + try: + out = func(*args) + except Exception as e: + exc = e + + # Restore privileges + set_privileges('root', current_uid, 0) + + if exc: + raise exc + + return out diff --git a/internal/policies/certificate/python/vendor_samba/gp/util/logging.py b/internal/policies/certificate/python/vendor_samba/gp/util/logging.py new file mode 100644 index 000000000..a74a8707d --- /dev/null +++ b/internal/policies/certificate/python/vendor_samba/gp/util/logging.py @@ -0,0 +1,112 @@ +# +# samba-gpupdate enhanced logging +# +# Copyright (C) 2019-2020 BaseALT Ltd. +# Copyright (C) David Mulder 2022 +# +# 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 3 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, see . + +import json +import datetime +import logging +import gettext +import random +import sys + +logger = logging.getLogger() +def logger_init(name, log_level): + logger = logging.getLogger(name) + logger.addHandler(logging.StreamHandler(sys.stdout)) + logger.setLevel(logging.CRITICAL) + if log_level == 1: + logger.setLevel(logging.ERROR) + elif log_level == 2: + logger.setLevel(logging.WARNING) + elif log_level == 3: + logger.setLevel(logging.INFO) + elif log_level >= 4: + logger.setLevel(logging.DEBUG) + +class slogm(object): + ''' + Structured log message class + ''' + def __init__(self, message, kwargs=None): + if kwargs is None: + kwargs = {} + self.message = message + self.kwargs = kwargs + if not isinstance(self.kwargs, dict): + self.kwargs = { 'val': self.kwargs } + + def __str__(self): + now = str(datetime.datetime.now().isoformat(sep=' ', timespec='milliseconds')) + args = dict() + args.update(self.kwargs) + result = '{}|{} | {}'.format(now, self.message, args) + + return result + +def message_with_code(mtype, message): + random.seed(message) + code = random.randint(0, 99999) + return '[' + mtype + str(code).rjust(5, '0') + ']| ' + \ + gettext.gettext(message) + +class log(object): + @staticmethod + def info(message, data=None): + if data is None: + data = {} + msg = message_with_code('I', message) + logger.info(slogm(msg, data)) + return msg + + @staticmethod + def warning(message, data=None): + if data is None: + data = {} + msg = message_with_code('W', message) + logger.warning(slogm(msg, data)) + return msg + + @staticmethod + def warn(message, data=None): + if data is None: + data = {} + return log.warning(message, data) + + @staticmethod + def error(message, data=None): + if data is None: + data = {} + msg = message_with_code('E', message) + logger.error(slogm(msg, data)) + return msg + + @staticmethod + def fatal(message, data=None): + if data is None: + data = {} + msg = message_with_code('F', message) + logger.fatal(slogm(msg, data)) + return msg + + @staticmethod + def debug(message, data=None): + if data is None: + data = {} + msg = message_with_code('D', message) + logger.debug(slogm(msg, data)) + return msg diff --git a/internal/policies/manager.go b/internal/policies/manager.go index 0c77642a1..e947d590c 100644 --- a/internal/policies/manager.go +++ b/internal/policies/manager.go @@ -33,10 +33,12 @@ import ( "time" "github.com/godbus/dbus/v5" + "github.com/ubuntu/adsys/internal/ad/backends" "github.com/ubuntu/adsys/internal/consts" log "github.com/ubuntu/adsys/internal/grpc/logstreamer" "github.com/ubuntu/adsys/internal/i18n" "github.com/ubuntu/adsys/internal/policies/apparmor" + "github.com/ubuntu/adsys/internal/policies/certificate" "github.com/ubuntu/adsys/internal/policies/dconf" "github.com/ubuntu/adsys/internal/policies/entry" "github.com/ubuntu/adsys/internal/policies/gdm" @@ -57,15 +59,19 @@ var ProOnlyRules = []string{"privilege", "scripts", "mount", "apparmor", "proxy" // Manager handles all managers for various policy handlers. type Manager struct { policiesCacheDir string + sysvolCacheDir string hostname string - dconf *dconf.Manager - privilege *privilege.Manager - scripts *scripts.Manager - mount *mount.Manager - gdm *gdm.Manager - apparmor *apparmor.Manager - proxy *proxy.Manager + backend backends.Backend + + dconf *dconf.Manager + privilege *privilege.Manager + scripts *scripts.Manager + mount *mount.Manager + gdm *gdm.Manager + apparmor *apparmor.Manager + proxy *proxy.Manager + certificate *certificate.Manager subscriptionDbus dbus.BusObject @@ -88,6 +94,7 @@ type systemdCaller interface { type options struct { cacheDir string + stateDir string dconfDir string sudoersDir string policyKitDir string @@ -195,7 +202,7 @@ func WithSystemdCaller(p systemdCaller) Option { } // NewManager returns a new manager with all default policy handlers. -func NewManager(bus *dbus.Conn, hostname string, opts ...Option) (m *Manager, err error) { +func NewManager(bus *dbus.Conn, hostname string, backend backends.Backend, opts ...Option) (m *Manager, err error) { defer decorate.OnError(&err, i18n.G("can't create a new policy handlers manager")) defaultSystemdCaller, err := systemd.New(bus) @@ -206,6 +213,7 @@ func NewManager(bus *dbus.Conn, hostname string, opts ...Option) (m *Manager, er // defaults args := options{ cacheDir: consts.DefaultCacheDir, + stateDir: consts.DefaultStateDir, runDir: consts.DefaultRunDir, apparmorDir: consts.DefaultApparmorDir, systemUnitDir: consts.DefaultSystemUnitDir, @@ -256,6 +264,13 @@ func NewManager(bus *dbus.Conn, hostname string, opts ...Option) (m *Manager, er } proxyManager := proxy.New(bus, proxyOptions...) + // certificate manager + certificateManager := certificate.New(backend.Domain(), + certificate.WithRunDir(args.runDir), + certificate.WithStateDir(args.stateDir), + certificate.WithCacheDir(args.cacheDir), + ) + // inject applied dconf mangager if we need to build a gdm manager if args.gdm == nil { if args.gdm, err = gdm.New(gdm.WithDconf(dconfManager)); err != nil { @@ -264,6 +279,7 @@ func NewManager(bus *dbus.Conn, hostname string, opts ...Option) (m *Manager, er } policiesCacheDir := filepath.Join(args.cacheDir, PoliciesCacheBaseName) + sysvolCacheDir := filepath.Join(args.cacheDir, "sysvol") if err := os.MkdirAll(policiesCacheDir, 0700); err != nil { return nil, err } @@ -272,7 +288,9 @@ func NewManager(bus *dbus.Conn, hostname string, opts ...Option) (m *Manager, er dbus.ObjectPath(consts.SubscriptionDbusObjectPath)) return &Manager{ + backend: backend, policiesCacheDir: policiesCacheDir, + sysvolCacheDir: sysvolCacheDir, hostname: hostname, dconf: dconfManager, privilege: privilegeManager, @@ -280,6 +298,7 @@ func NewManager(bus *dbus.Conn, hostname string, opts ...Option) (m *Manager, er mount: mountManager, apparmor: apparmorManager, proxy: proxyManager, + certificate: certificateManager, gdm: args.gdm, subscriptionDbus: subscriptionDbus, @@ -304,6 +323,7 @@ func (m *Manager) ApplyPolicies(ctx context.Context, objectName string, isComput m.muMu.Unlock() rules := pols.GetUniqueRules() + gpoPaths := m.GPOPaths(ctx, pols) action := i18n.G("Applying") if len(rules) == 0 { action = i18n.G("Unloading") @@ -337,6 +357,11 @@ func (m *Manager) ApplyPolicies(ctx context.Context, objectName string, isComput g.Go(func() error { return m.proxy.ApplyPolicy(ctx, objectName, isComputer, rules["proxy"]) }) + g.Go(func() error { + // Ignore error as we don't want to fail because of online status this late in the process + isOnline, _ := m.backend.IsOnline() + return m.certificate.ApplyPolicy(ctx, objectName, isComputer, isOnline, gpoPaths) + }) if err := g.Wait(); err != nil { return err } @@ -404,6 +429,20 @@ func (m *Manager) LastUpdateFor(ctx context.Context, objectName string, isMachin return info.ModTime(), nil } +func (m *Manager) GPOPaths(ctx context.Context, pols *Policies) []string { + var paths []string + for _, gpo := range pols.GPOs { + gpoPath := filepath.Join(m.sysvolCacheDir, "Policies", gpo.ID) + if _, err := os.Stat(gpoPath); err != nil { + log.Warningf(ctx, "GPO %q with ID %q not found in sysvol cache", gpo.Name, gpo.ID) + continue + } + paths = append(paths, gpoPath) + } + + return paths +} + // GetSubscriptionState returns the subscription status from Ubuntu Pro. func (m *Manager) GetSubscriptionState(ctx context.Context) (subscriptionEnabled bool) { log.Debug(ctx, "Refresh subscription state") From 6637f93098501587aaf9747a5041db3e970dad88 Mon Sep 17 00:00:00 2001 From: Gabriel Nagy Date: Mon, 17 Jul 2023 12:29:16 +0300 Subject: [PATCH 2/2] Add CLI command to dump certificate enroll script Similar to the adsys-gpolist, provide a way for users to dump the certificate autoenrollment script for debugging purposes. --- adsys.pb.go | 40 ++++++++++++--------- adsys.proto | 1 + adsys_grpc.pb.go | 64 +++++++++++++++++++++++++++++++++ cmd/adsysd/client/policy.go | 30 +++++++++++++++- internal/adsysservice/policy.go | 18 ++++++++++ 5 files changed, 135 insertions(+), 18 deletions(-) diff --git a/adsys.pb.go b/adsys.pb.go index 1b84188d6..9a5ddae6e 100644 --- a/adsys.pb.go +++ b/adsys.pb.go @@ -596,7 +596,7 @@ var file_adsys_proto_rawDesc = []byte{ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x70, 0x74, 0x65, 0x72, 0x22, 0x22, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x44, 0x6f, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x72, 0x61, 0x77, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x72, - 0x61, 0x77, 0x32, 0x96, 0x04, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x20, + 0x61, 0x77, 0x32, 0xc9, 0x04, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x20, 0x0a, 0x03, 0x43, 0x61, 0x74, 0x12, 0x06, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0f, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x24, 0x0a, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x2e, 0x45, 0x6d, @@ -629,9 +629,13 @@ var file_adsys_proto_rawDesc = []byte{ 0x74, 0x72, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x2a, 0x0a, 0x0d, 0x47, 0x50, 0x4f, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x12, 0x06, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0f, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, - 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x42, 0x19, 0x5a, 0x17, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x75, 0x62, 0x75, 0x6e, 0x74, 0x75, - 0x2f, 0x61, 0x64, 0x73, 0x79, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x31, 0x0a, 0x14, 0x43, + 0x65, 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x45, 0x6e, 0x72, 0x6f, 0x6c, 0x6c, 0x53, 0x63, 0x72, + 0x69, 0x70, 0x74, 0x12, 0x06, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0f, 0x2e, 0x53, 0x74, + 0x72, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x42, 0x19, + 0x5a, 0x17, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x75, 0x62, 0x75, + 0x6e, 0x74, 0x75, 0x2f, 0x61, 0x64, 0x73, 0x79, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, } var ( @@ -671,19 +675,21 @@ var file_adsys_proto_depIdxs = []int32{ 9, // 8: service.ListDoc:input_type -> ListDocRequest 1, // 9: service.ListUsers:input_type -> ListUsersRequest 0, // 10: service.GPOListScript:input_type -> Empty - 3, // 11: service.Cat:output_type -> StringResponse - 3, // 12: service.Version:output_type -> StringResponse - 3, // 13: service.Status:output_type -> StringResponse - 0, // 14: service.Stop:output_type -> Empty - 0, // 15: service.UpdatePolicy:output_type -> Empty - 3, // 16: service.DumpPolicies:output_type -> StringResponse - 7, // 17: service.DumpPoliciesDefinitions:output_type -> DumpPolicyDefinitionsResponse - 3, // 18: service.GetDoc:output_type -> StringResponse - 3, // 19: service.ListDoc:output_type -> StringResponse - 3, // 20: service.ListUsers:output_type -> StringResponse - 3, // 21: service.GPOListScript:output_type -> StringResponse - 11, // [11:22] is the sub-list for method output_type - 0, // [0:11] is the sub-list for method input_type + 0, // 11: service.CertAutoEnrollScript:input_type -> Empty + 3, // 12: service.Cat:output_type -> StringResponse + 3, // 13: service.Version:output_type -> StringResponse + 3, // 14: service.Status:output_type -> StringResponse + 0, // 15: service.Stop:output_type -> Empty + 0, // 16: service.UpdatePolicy:output_type -> Empty + 3, // 17: service.DumpPolicies:output_type -> StringResponse + 7, // 18: service.DumpPoliciesDefinitions:output_type -> DumpPolicyDefinitionsResponse + 3, // 19: service.GetDoc:output_type -> StringResponse + 3, // 20: service.ListDoc:output_type -> StringResponse + 3, // 21: service.ListUsers:output_type -> StringResponse + 3, // 22: service.GPOListScript:output_type -> StringResponse + 3, // 23: service.CertAutoEnrollScript:output_type -> StringResponse + 12, // [12:24] is the sub-list for method output_type + 0, // [0:12] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name diff --git a/adsys.proto b/adsys.proto index cc95a16a2..307b28ca5 100644 --- a/adsys.proto +++ b/adsys.proto @@ -14,6 +14,7 @@ service service { rpc ListDoc(ListDocRequest) returns (stream StringResponse); rpc ListUsers(ListUsersRequest) returns (stream StringResponse); rpc GPOListScript(Empty) returns (stream StringResponse); + rpc CertAutoEnrollScript(Empty) returns (stream StringResponse); } message Empty {} diff --git a/adsys_grpc.pb.go b/adsys_grpc.pb.go index 787185740..1bc7b2b16 100644 --- a/adsys_grpc.pb.go +++ b/adsys_grpc.pb.go @@ -30,6 +30,7 @@ const ( Service_ListDoc_FullMethodName = "/service/ListDoc" Service_ListUsers_FullMethodName = "/service/ListUsers" Service_GPOListScript_FullMethodName = "/service/GPOListScript" + Service_CertAutoEnrollScript_FullMethodName = "/service/CertAutoEnrollScript" ) // ServiceClient is the client API for Service service. @@ -47,6 +48,7 @@ type ServiceClient interface { ListDoc(ctx context.Context, in *ListDocRequest, opts ...grpc.CallOption) (Service_ListDocClient, error) ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (Service_ListUsersClient, error) GPOListScript(ctx context.Context, in *Empty, opts ...grpc.CallOption) (Service_GPOListScriptClient, error) + CertAutoEnrollScript(ctx context.Context, in *Empty, opts ...grpc.CallOption) (Service_CertAutoEnrollScriptClient, error) } type serviceClient struct { @@ -409,6 +411,38 @@ func (x *serviceGPOListScriptClient) Recv() (*StringResponse, error) { return m, nil } +func (c *serviceClient) CertAutoEnrollScript(ctx context.Context, in *Empty, opts ...grpc.CallOption) (Service_CertAutoEnrollScriptClient, error) { + stream, err := c.cc.NewStream(ctx, &Service_ServiceDesc.Streams[11], Service_CertAutoEnrollScript_FullMethodName, opts...) + if err != nil { + return nil, err + } + x := &serviceCertAutoEnrollScriptClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type Service_CertAutoEnrollScriptClient interface { + Recv() (*StringResponse, error) + grpc.ClientStream +} + +type serviceCertAutoEnrollScriptClient struct { + grpc.ClientStream +} + +func (x *serviceCertAutoEnrollScriptClient) Recv() (*StringResponse, error) { + m := new(StringResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + // ServiceServer is the server API for Service service. // All implementations must embed UnimplementedServiceServer // for forward compatibility @@ -424,6 +458,7 @@ type ServiceServer interface { ListDoc(*ListDocRequest, Service_ListDocServer) error ListUsers(*ListUsersRequest, Service_ListUsersServer) error GPOListScript(*Empty, Service_GPOListScriptServer) error + CertAutoEnrollScript(*Empty, Service_CertAutoEnrollScriptServer) error mustEmbedUnimplementedServiceServer() } @@ -464,6 +499,9 @@ func (UnimplementedServiceServer) ListUsers(*ListUsersRequest, Service_ListUsers func (UnimplementedServiceServer) GPOListScript(*Empty, Service_GPOListScriptServer) error { return status.Errorf(codes.Unimplemented, "method GPOListScript not implemented") } +func (UnimplementedServiceServer) CertAutoEnrollScript(*Empty, Service_CertAutoEnrollScriptServer) error { + return status.Errorf(codes.Unimplemented, "method CertAutoEnrollScript not implemented") +} func (UnimplementedServiceServer) mustEmbedUnimplementedServiceServer() {} // UnsafeServiceServer may be embedded to opt out of forward compatibility for this service. @@ -708,6 +746,27 @@ func (x *serviceGPOListScriptServer) Send(m *StringResponse) error { return x.ServerStream.SendMsg(m) } +func _Service_CertAutoEnrollScript_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(ServiceServer).CertAutoEnrollScript(m, &serviceCertAutoEnrollScriptServer{stream}) +} + +type Service_CertAutoEnrollScriptServer interface { + Send(*StringResponse) error + grpc.ServerStream +} + +type serviceCertAutoEnrollScriptServer struct { + grpc.ServerStream +} + +func (x *serviceCertAutoEnrollScriptServer) Send(m *StringResponse) error { + return x.ServerStream.SendMsg(m) +} + // Service_ServiceDesc is the grpc.ServiceDesc for Service service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -771,6 +830,11 @@ var Service_ServiceDesc = grpc.ServiceDesc{ Handler: _Service_GPOListScript_Handler, ServerStreams: true, }, + { + StreamName: "CertAutoEnrollScript", + Handler: _Service_CertAutoEnrollScript_Handler, + ServerStreams: true, + }, }, Metadata: "adsys.proto", } diff --git a/cmd/adsysd/client/policy.go b/cmd/adsysd/client/policy.go index 7765ae060..c00aa0330 100644 --- a/cmd/adsysd/client/policy.go +++ b/cmd/adsysd/client/policy.go @@ -80,12 +80,20 @@ func (a *App) installPolicy() { policyCmd.AddCommand(debugCmd) gpoListCmd := &cobra.Command{ Use: "gpolist-script", - Short: i18n.G("Write GPO list python embeeded script in current directory"), + Short: i18n.G("Write GPO list python embedded script in current directory"), Args: cobra.NoArgs, ValidArgsFunction: cmdhandler.NoValidArgs, RunE: func(cmd *cobra.Command, args []string) error { return a.dumpGPOListScript() }, } debugCmd.AddCommand(gpoListCmd) + certEnrollCmd := &cobra.Command{ + Use: "cert-autoenroll-script", + Short: i18n.G("Write certificate autoenrollment python embedded script in current directory"), + Args: cobra.NoArgs, + ValidArgsFunction: cmdhandler.NoValidArgs, + RunE: func(cmd *cobra.Command, args []string) error { return a.dumpCertEnrollScript() }, + } + debugCmd.AddCommand(certEnrollCmd) var updateMachine, updateAll *bool updateCmd := &cobra.Command{ @@ -271,6 +279,26 @@ func (a *App) dumpGPOListScript() error { return os.WriteFile("adsys-gpolist", []byte(script), 0600) } +func (a *App) dumpCertEnrollScript() error { + client, err := adsysservice.NewClient(a.config.Socket, a.getTimeout()) + if err != nil { + return err + } + defer client.Close() + + stream, err := client.CertAutoEnrollScript(a.ctx, &adsys.Empty{}) + if err != nil { + return err + } + + script, err := singleMsg(stream) + if err != nil { + return err + } + + return os.WriteFile("cert-autoenroll", []byte(script), 0600) +} + func colorizePolicies(policies string) (string, error) { first := true var out stringsBuilderWithError diff --git a/internal/adsysservice/policy.go b/internal/adsysservice/policy.go index ab498e398..f30e0ca46 100644 --- a/internal/adsysservice/policy.go +++ b/internal/adsysservice/policy.go @@ -11,6 +11,7 @@ import ( log "github.com/ubuntu/adsys/internal/grpc/logstreamer" "github.com/ubuntu/adsys/internal/i18n" "github.com/ubuntu/adsys/internal/policies" + "github.com/ubuntu/adsys/internal/policies/certificate" "github.com/ubuntu/decorate" "golang.org/x/sync/errgroup" ) @@ -156,4 +157,21 @@ func (s *Service) GPOListScript(_ *adsys.Empty, stream adsys.Service_GPOListScri return nil } +// CertAutoEnrollScript returns the embedded certificate autoenrollment python script. +func (s *Service) CertAutoEnrollScript(_ *adsys.Empty, stream adsys.Service_CertAutoEnrollScriptServer) (err error) { + defer decorate.OnError(&err, i18n.G("error while getting certificate autoenrollment script")) + + if err := s.authorizer.IsAllowedFromContext(stream.Context(), authorizer.ActionAlwaysAllowed); err != nil { + return err + } + + if err := stream.Send(&adsys.StringResponse{ + Msg: certificate.CertEnrollCode, + }); err != nil { + log.Warningf(stream.Context(), "couldn't send certificate autoenrollment script to client: %v", err) + } + + return nil +} + // FIXME: check cache file permission