ipsilon-client-install give password in env. var.
[cascardo/ipsilon.git] / ipsilon / install / ipsilon-client-install
1 #!/usr/bin/python
2 #
3 # Copyright (C) 2014 Ipsilon project Contributors, for license see COPYING
4
5 from ipsilon.tools.saml2metadata import Metadata
6 from ipsilon.tools.saml2metadata import SAML2_NAMEID_MAP
7 from ipsilon.tools.saml2metadata import SAML2_SERVICE_MAP
8 from ipsilon.tools.certs import Certificate
9 from ipsilon.tools import files
10 from urllib import urlencode
11 import argparse
12 import ConfigParser
13 import getpass
14 import json
15 import logging
16 import os
17 import pwd
18 import requests
19 import shutil
20 import socket
21 import sys
22
23
24 HTTPDCONFD = '/etc/httpd/conf.d'
25 SAML2_TEMPLATE = '/usr/share/ipsilon/templates/install/saml2/sp.conf'
26 SAML2_CONFFILE = '/etc/httpd/conf.d/ipsilon-saml.conf'
27 SAML2_HTTPDIR = '/etc/httpd/saml2'
28 SAML2_PROTECTED = '/saml2protected'
29
30 #Installation arguments
31 args = dict()
32
33 # Regular logging
34 logger = logging.getLogger()
35
36
37 def openlogs():
38     global logger  # pylint: disable=W0603
39     logger = logging.getLogger()
40     lh = logging.StreamHandler(sys.stderr)
41     logger.addHandler(lh)
42
43
44 def saml2():
45     logger.info('Installing SAML2 Service Provider')
46
47     if args['saml_idp_metadata'] is None:
48         #TODO: detect via SRV records ?
49         if args['saml_idp_url']:
50             args['saml_idp_metadata'] = ('%s/saml2/metadata' %
51                                          args['saml_idp_url'].rstrip('/'))
52         else:
53             raise ValueError('An IDP URL or metadata file/URL is required.')
54
55     idpmeta = None
56
57     try:
58         if os.path.exists(args['saml_idp_metadata']):
59             with open(args['saml_idp_metadata']) as f:
60                 idpmeta = f.read()
61         elif args['saml_idp_metadata'].startswith('file://'):
62             with open(args['saml_idp_metadata'][7:]) as f:
63                 idpmeta = f.read()
64         else:
65             r = requests.get(args['saml_idp_metadata'])
66             r.raise_for_status()
67             idpmeta = r.content
68     except Exception, e:  # pylint: disable=broad-except
69         logger.error("Failed to retrieve IDP Metadata file!\n" +
70                      "Error: [%s]" % repr(e))
71         raise
72
73     path = None
74     if not args['saml_no_httpd']:
75         path = os.path.join(SAML2_HTTPDIR, args['hostname'])
76         os.makedirs(path, 0750)
77     else:
78         path = os.getcwd()
79
80     proto = 'https'
81     if not args['saml_secure_setup']:
82         proto = 'http'
83
84     port_str = ''
85     if args['port']:
86         port_str = ':%s' % args['port']
87
88     url = '%s://%s%s' % (proto, args['hostname'], port_str)
89     url_sp = url + args['saml_sp']
90     url_logout = url + args['saml_sp_logout']
91     url_post = url + args['saml_sp_post']
92
93     # Generate metadata
94     m = Metadata('sp')
95     c = Certificate(path)
96     c.generate('certificate', args['hostname'])
97     m.set_entity_id(url_sp)
98     m.add_certs(c)
99     m.add_service(SAML2_SERVICE_MAP['logout-redirect'], url_logout)
100     m.add_service(SAML2_SERVICE_MAP['response-post'], url_post, index="0")
101     m.add_allowed_name_format(SAML2_NAMEID_MAP[args['saml_nameid']])
102     sp_metafile = os.path.join(path, 'metadata.xml')
103     m.output(sp_metafile)
104
105     # Register with the IDP if the IDP URL was provided
106     if args['saml_idp_url']:
107         if args['admin_password']:
108             if args['admin_password'] == '-':
109                 admin_password = sys.stdin.readline().rstrip('\n')
110             else:
111                 try:
112                     with open(args['admin_password']) as f:
113                         admin_password = f.read().rstrip('\n')
114                 except Exception as e:  # pylint: disable=broad-except
115                     logger.error("Failed to read password file!\n" +
116                                  "Error: [%s]" % e)
117                     raise
118         elif ('IPSILON_ADMIN_PASSWORD' in os.environ) and \
119              (os.environ['IPSILON_ADMIN_PASSWORD']):
120             admin_password = os.environ['IPSILON_ADMIN_PASSWORD']
121         else:
122             admin_password = getpass.getpass('%s password: ' %
123                                              args['admin_user'])
124
125         # Read our metadata
126         sp_metadata = ''
127         try:
128             with open(sp_metafile) as f:
129                 for line in f:
130                     sp_metadata += line.strip()
131         except Exception as e:  # pylint: disable=broad-except
132             logger.error("Failed to read SP Metadata file!\n" +
133                          "Error: [%s]" % e)
134             raise
135
136         # Register the SP
137         try:
138             saml2_register_sp(args['saml_idp_url'], args['admin_user'],
139                               admin_password, args['saml_sp_name'],
140                               sp_metadata)
141         except Exception as e:  # pylint: disable=broad-except
142             logger.error("Failed to register SP with IDP!\n" +
143                          "Error: [%s]" % e)
144             raise
145
146     if not args['saml_no_httpd']:
147         idp_metafile = os.path.join(path, 'idp-metadata.xml')
148         with open(idp_metafile, 'w+') as f:
149             f.write(idpmeta)
150
151         saml_protect = 'auth'
152         saml_auth=''
153         if args['saml_base'] != args['saml_auth']:
154             saml_protect = 'info'
155             saml_auth = '<Location %s>\n' \
156                         '    MellonEnable "auth"\n' \
157                         '    Header append Cache-Control "no-cache"\n' \
158                         '</Location>\n' % args['saml_auth']
159
160         psp = '# '
161         if args['saml_auth'] == SAML2_PROTECTED:
162             # default location, enable the default page
163             psp = ''
164
165         saml_secure = 'Off'
166         ssl_require = '#'
167         ssl_rewrite = '#'
168         if args['port']:
169             ssl_port = args['port']
170         else:
171             ssl_port = '443'
172
173         if args['saml_secure_setup']:
174             saml_secure = 'On'
175             ssl_require = ''
176             ssl_rewrite = ''
177
178         samlopts = {'saml_base': args['saml_base'],
179                     'saml_protect': saml_protect,
180                     'saml_sp_key': c.key,
181                     'saml_sp_cert': c.cert,
182                     'saml_sp_meta': sp_metafile,
183                     'saml_idp_meta': idp_metafile,
184                     'saml_sp': args['saml_sp'],
185                     'saml_secure_on': saml_secure,
186                     'saml_auth': saml_auth,
187                     'ssl_require': ssl_require,
188                     'ssl_rewrite': ssl_rewrite,
189                     'ssl_port': ssl_port,
190                     'sp_hostname': args['hostname'],
191                     'sp_port': port_str,
192                     'sp': psp}
193         files.write_from_template(SAML2_CONFFILE, SAML2_TEMPLATE, samlopts)
194
195         files.fix_user_dirs(SAML2_HTTPDIR, args['httpd_user'])
196
197         logger.info('SAML Service Provider configured.')
198         logger.info('You should be able to restart the HTTPD server and' +
199                     ' then access it at %s%s' % (url, args['saml_auth']))
200     else:
201         logger.info('SAML Service Provider configuration ready.')
202         logger.info('Use the certificate, key and metadata.xml files to' +
203                     ' configure your Service Provider')
204
205
206 def saml2_register_sp(url, user, password, sp_name, sp_metadata):
207     s = requests.Session()
208
209     # Authenticate to the IdP
210     form_auth_url = '%s/login/form' % url.rstrip('/')
211     test_auth_url = '%s/login/testauth' % url.rstrip('/')
212     auth_data = {'login_name': user,
213                  'login_password': password}
214
215     r = s.post(form_auth_url, data=auth_data)
216     if r.status_code == 404:
217         r = s.post(test_auth_url, data=auth_data)
218
219     if r.status_code != 200:
220         raise Exception('Unable to authenticate to IdP (%d)' % r.status_code)
221
222     # Add the SP
223     sp_url = '%s/rest/providers/saml2/SPS/%s' % (url.rstrip('/'), sp_name)
224     sp_headers = {'Content-type': 'application/x-www-form-urlencoded',
225                   'Referer': sp_url}
226     sp_data = urlencode({'metadata': sp_metadata})
227
228     r = s.post(sp_url, headers=sp_headers, data=sp_data)
229     if r.status_code != 201:
230         message = json.loads(r.text)['message']
231         raise Exception('%s' % message)
232
233
234 def install():
235     if args['saml']:
236         saml2()
237
238
239 def saml2_uninstall():
240     try:
241         shutil.rmtree(os.path.join(SAML2_HTTPDIR, args['hostname']))
242     except Exception, e:  # pylint: disable=broad-except
243         log_exception(e)
244     try:
245         os.remove(SAML2_CONFFILE)
246     except Exception, e:  # pylint: disable=broad-except
247         log_exception(e)
248
249
250 def uninstall():
251     logger.info('Uninstalling Service Provider')
252     #FXIME: ask confirmation
253     saml2_uninstall()
254     logger.info('Uninstalled SAML2 data')
255
256
257 def log_exception(e):
258     if 'debug' in args and args['debug']:
259         logger.exception(e)
260     else:
261         logger.error(e)
262
263
264 def parse_config_profile(args):
265     config = ConfigParser.ConfigParser()
266     files = config.read(args['config_profile'])
267     if len(files) == 0:
268         raise ConfigurationError('Config Profile file %s not found!' %
269                                  args['config_profile'])
270
271     if 'globals' in config.sections():
272         G = config.options('globals')
273         for g in G:
274             val = config.get('globals', g)
275             if val == 'False':
276                 val = False
277             elif val == 'True':
278                 val = True
279             if g in globals():
280                 globals()[g] = val
281             else:
282                 for k in globals().keys():
283                     if k.lower() == g.lower():
284                         globals()[k] = val
285                         break
286
287     if 'arguments' in config.sections():
288         A = config.options('arguments')
289         for a in A:
290             val = config.get('arguments', a)
291             if val == 'False':
292                 val = False
293             elif val == 'True':
294                 val = True
295             args[a] = val
296
297     return args
298
299
300 def parse_args():
301     global args
302
303     fc = argparse.ArgumentDefaultsHelpFormatter
304     parser = argparse.ArgumentParser(description='Client Install Options',
305                                      formatter_class=fc)
306     parser.add_argument('--version',
307                         action='version', version='%(prog)s 0.1')
308     parser.add_argument('--hostname', default=socket.getfqdn(),
309                         help="Machine's fully qualified host name")
310     parser.add_argument('--port', default=None,
311                         help="Port number that SP listens on")
312     parser.add_argument('--admin-user', default='admin',
313                         help="Account allowed to create a SP")
314     parser.add_argument('--admin-password', default=None,
315                         help="File containing the password for the account " +
316                              "used to create a SP (- to read from stdin)")
317     parser.add_argument('--httpd-user', default='apache',
318                         help="Web server account used to read certs")
319     parser.add_argument('--saml', action='store_true', default=True,
320                         help="Whether to install a saml2 SP")
321     parser.add_argument('--saml-idp-url', default=None,
322                         help="A URL of the IDP to register the SP with")
323     parser.add_argument('--saml-idp-metadata', default=None,
324                         help="A URL pointing at the IDP Metadata (FILE or HTTP)")
325     parser.add_argument('--saml-no-httpd', action='store_true', default=False,
326                         help="Do not configure httpd")
327     parser.add_argument('--saml-base', default='/',
328                         help="Where saml2 authdata is available")
329     parser.add_argument('--saml-auth', default=SAML2_PROTECTED,
330                         help="Where saml2 authentication is enforced")
331     parser.add_argument('--saml-sp', default='/saml2',
332                         help="Where saml communication happens")
333     parser.add_argument('--saml-sp-logout', default='/saml2/logout',
334                         help="Single Logout URL")
335     parser.add_argument('--saml-sp-post', default='/saml2/postResponse',
336                         help="Post response URL")
337     parser.add_argument('--saml-secure-setup', action='store_true',
338                         default=True, help="Turn on all security checks")
339     parser.add_argument('--saml-nameid', default='unspecified',
340                         choices=SAML2_NAMEID_MAP.keys(),
341                         help="SAML NameID format to use")
342     parser.add_argument('--saml-sp-name', default=None,
343                         help="The SP name to register with the IdP")
344     parser.add_argument('--debug', action='store_true', default=False,
345                         help="Turn on script debugging")
346     parser.add_argument('--config-profile', default=None,
347                         help=argparse.SUPPRESS)
348     parser.add_argument('--uninstall', action='store_true',
349                         help="Uninstall the server and all data")
350
351     args = vars(parser.parse_args())
352
353     if args['config_profile']:
354         args = parse_config_profile(args)
355
356     if len(args['hostname'].split('.')) < 2:
357         raise ValueError('Hostname: %s is not a FQDN.' % args['hostname'])
358
359     if args['port'] and not args['port'].isdigit():
360         raise ValueError('Port number: %s is not an integer.' % args['port'])
361
362     # Validate that all path options begin with '/'
363     path_args = ['saml_base', 'saml_auth', 'saml_sp', 'saml_sp_logout',
364                  'saml_sp_post']
365     for path_arg in path_args:
366         if not args[path_arg].startswith('/'):
367             raise ValueError('--%s must begin with a / character.' %
368                              path_arg.replace('_', '-'))
369
370     # The saml_sp setting must be a subpath of saml_base since it is
371     # used as the MellonEndpointPath.
372     if not args['saml_sp'].startswith(args['saml_base']):
373         raise ValueError('--saml-sp must be a subpath of --saml-base.')
374
375     # The saml_sp_logout and saml_sp_post settings must be subpaths
376     # of saml_sp (the mellon endpoint).
377     path_args = ['saml_sp_logout', 'saml_sp_post']
378     for path_arg in path_args:
379         if not args[path_arg].startswith(args['saml_sp']):
380             raise ValueError('--%s must be a subpath of --saml-sp' %
381                              path_arg.replace('_', '-'))
382
383     # If saml_idp_url if being used, we require saml_sp_name to
384     # use when registering the SP.
385     if args['saml_idp_url'] and not args['saml_sp_name']:
386         raise ValueError('--saml-sp-name must be specified when using' +
387                          '--saml-idp-url')
388
389     # At least one on this list needs to be specified or we do nothing
390     sp_list = ['saml']
391     present = False
392     for sp in sp_list:
393         if args[sp]:
394             present = True
395     if not present and not args['uninstall']:
396         raise ValueError('Nothing to install, please select a Service type.')
397
398
399 if __name__ == '__main__':
400     out = 0
401     openlogs()
402     try:
403         parse_args()
404
405         if 'uninstall' in args and args['uninstall'] is True:
406             uninstall()
407         else:
408             install()
409     except Exception, e:  # pylint: disable=broad-except
410         log_exception(e)
411         if 'uninstall' in args and args['uninstall'] is True:
412             logging.info('Uninstallation aborted.')
413         else:
414             logging.info('Installation aborted.')
415         out = 1
416     finally:
417         if out == 0:
418             if 'uninstall' in args and args['uninstall'] is True:
419                 logging.info('Uninstallation complete.')
420             else:
421                 logging.info('Installation complete.')
422     sys.exit(out)