Update Copyright header point to COPYING file
[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         else:
119             admin_password = getpass.getpass('%s password: ' %
120                                              args['admin_user'])
121
122         # Read our metadata
123         sp_metadata = ''
124         try:
125             with open(sp_metafile) as f:
126                 for line in f:
127                     sp_metadata += line.strip()
128         except Exception as e:  # pylint: disable=broad-except
129             logger.error("Failed to read SP Metadata file!\n" +
130                          "Error: [%s]" % e)
131             raise
132
133         # Register the SP
134         try:
135             saml2_register_sp(args['saml_idp_url'], args['admin_user'],
136                               admin_password, args['saml_sp_name'],
137                               sp_metadata)
138         except Exception as e:  # pylint: disable=broad-except
139             logger.error("Failed to register SP with IDP!\n" +
140                          "Error: [%s]" % e)
141             raise
142
143     if not args['saml_no_httpd']:
144         idp_metafile = os.path.join(path, 'idp-metadata.xml')
145         with open(idp_metafile, 'w+') as f:
146             f.write(idpmeta)
147
148         saml_protect = 'auth'
149         saml_auth=''
150         if args['saml_base'] != args['saml_auth']:
151             saml_protect = 'info'
152             saml_auth = '<Location %s>\n' \
153                         '    MellonEnable "auth"\n' \
154                         '    Header append Cache-Control "no-cache"\n' \
155                         '</Location>\n' % args['saml_auth']
156
157         psp = '# '
158         if args['saml_auth'] == SAML2_PROTECTED:
159             # default location, enable the default page
160             psp = ''
161
162         saml_secure = 'Off'
163         ssl_require = '#'
164         ssl_rewrite = '#'
165         if args['port']:
166             ssl_port = args['port']
167         else:
168             ssl_port = '443'
169
170         if args['saml_secure_setup']:
171             saml_secure = 'On'
172             ssl_require = ''
173             ssl_rewrite = ''
174
175         samlopts = {'saml_base': args['saml_base'],
176                     'saml_protect': saml_protect,
177                     'saml_sp_key': c.key,
178                     'saml_sp_cert': c.cert,
179                     'saml_sp_meta': sp_metafile,
180                     'saml_idp_meta': idp_metafile,
181                     'saml_sp': args['saml_sp'],
182                     'saml_secure_on': saml_secure,
183                     'saml_auth': saml_auth,
184                     'ssl_require': ssl_require,
185                     'ssl_rewrite': ssl_rewrite,
186                     'ssl_port': ssl_port,
187                     'sp_hostname': args['hostname'],
188                     'sp_port': port_str,
189                     'sp': psp}
190         files.write_from_template(SAML2_CONFFILE, SAML2_TEMPLATE, samlopts)
191
192         files.fix_user_dirs(SAML2_HTTPDIR, args['httpd_user'])
193
194         logger.info('SAML Service Provider configured.')
195         logger.info('You should be able to restart the HTTPD server and' +
196                     ' then access it at %s%s' % (url, args['saml_auth']))
197     else:
198         logger.info('SAML Service Provider configuration ready.')
199         logger.info('Use the certificate, key and metadata.xml files to' +
200                     ' configure your Service Provider')
201
202
203 def saml2_register_sp(url, user, password, sp_name, sp_metadata):
204     s = requests.Session()
205
206     # Authenticate to the IdP
207     form_auth_url = '%s/login/form' % url.rstrip('/')
208     test_auth_url = '%s/login/testauth' % url.rstrip('/')
209     auth_data = {'login_name': user,
210                  'login_password': password}
211
212     r = s.post(form_auth_url, data=auth_data)
213     if r.status_code == 404:
214         r = s.post(test_auth_url, data=auth_data)
215
216     if r.status_code != 200:
217         raise Exception('Unable to authenticate to IdP (%d)' % r.status_code)
218
219     # Add the SP
220     sp_url = '%s/rest/providers/saml2/SPS/%s' % (url.rstrip('/'), sp_name)
221     sp_headers = {'Content-type': 'application/x-www-form-urlencoded',
222                   'Referer': sp_url}
223     sp_data = urlencode({'metadata': sp_metadata})
224
225     r = s.post(sp_url, headers=sp_headers, data=sp_data)
226     if r.status_code != 201:
227         message = json.loads(r.text)['message']
228         raise Exception('%s' % message)
229
230
231 def install():
232     if args['saml']:
233         saml2()
234
235
236 def saml2_uninstall():
237     try:
238         shutil.rmtree(os.path.join(SAML2_HTTPDIR, args['hostname']))
239     except Exception, e:  # pylint: disable=broad-except
240         log_exception(e)
241     try:
242         os.remove(SAML2_CONFFILE)
243     except Exception, e:  # pylint: disable=broad-except
244         log_exception(e)
245
246
247 def uninstall():
248     logger.info('Uninstalling Service Provider')
249     #FXIME: ask confirmation
250     saml2_uninstall()
251     logger.info('Uninstalled SAML2 data')
252
253
254 def log_exception(e):
255     if 'debug' in args and args['debug']:
256         logger.exception(e)
257     else:
258         logger.error(e)
259
260
261 def parse_config_profile(args):
262     config = ConfigParser.ConfigParser()
263     files = config.read(args['config_profile'])
264     if len(files) == 0:
265         raise ConfigurationError('Config Profile file %s not found!' %
266                                  args['config_profile'])
267
268     if 'globals' in config.sections():
269         G = config.options('globals')
270         for g in G:
271             val = config.get('globals', g)
272             if val == 'False':
273                 val = False
274             elif val == 'True':
275                 val = True
276             if g in globals():
277                 globals()[g] = val
278             else:
279                 for k in globals().keys():
280                     if k.lower() == g.lower():
281                         globals()[k] = val
282                         break
283
284     if 'arguments' in config.sections():
285         A = config.options('arguments')
286         for a in A:
287             val = config.get('arguments', a)
288             if val == 'False':
289                 val = False
290             elif val == 'True':
291                 val = True
292             args[a] = val
293
294     return args
295
296
297 def parse_args():
298     global args
299
300     fc = argparse.ArgumentDefaultsHelpFormatter
301     parser = argparse.ArgumentParser(description='Client Install Options',
302                                      formatter_class=fc)
303     parser.add_argument('--version',
304                         action='version', version='%(prog)s 0.1')
305     parser.add_argument('--hostname', default=socket.getfqdn(),
306                         help="Machine's fully qualified host name")
307     parser.add_argument('--port', default=None,
308                         help="Port number that SP listens on")
309     parser.add_argument('--admin-user', default='admin',
310                         help="Account allowed to create a SP")
311     parser.add_argument('--admin-password', default=None,
312                         help="File containing the password for the account " +
313                              "used to create a SP (- to read from stdin)")
314     parser.add_argument('--httpd-user', default='apache',
315                         help="Web server account used to read certs")
316     parser.add_argument('--saml', action='store_true', default=True,
317                         help="Whether to install a saml2 SP")
318     parser.add_argument('--saml-idp-url', default=None,
319                         help="A URL of the IDP to register the SP with")
320     parser.add_argument('--saml-idp-metadata', default=None,
321                         help="A URL pointing at the IDP Metadata (FILE or HTTP)")
322     parser.add_argument('--saml-no-httpd', action='store_true', default=False,
323                         help="Do not configure httpd")
324     parser.add_argument('--saml-base', default='/',
325                         help="Where saml2 authdata is available")
326     parser.add_argument('--saml-auth', default=SAML2_PROTECTED,
327                         help="Where saml2 authentication is enforced")
328     parser.add_argument('--saml-sp', default='/saml2',
329                         help="Where saml communication happens")
330     parser.add_argument('--saml-sp-logout', default='/saml2/logout',
331                         help="Single Logout URL")
332     parser.add_argument('--saml-sp-post', default='/saml2/postResponse',
333                         help="Post response URL")
334     parser.add_argument('--saml-secure-setup', action='store_true',
335                         default=True, help="Turn on all security checks")
336     parser.add_argument('--saml-nameid', default='unspecified',
337                         choices=SAML2_NAMEID_MAP.keys(),
338                         help="SAML NameID format to use")
339     parser.add_argument('--saml-sp-name', default=None,
340                         help="The SP name to register with the IdP")
341     parser.add_argument('--debug', action='store_true', default=False,
342                         help="Turn on script debugging")
343     parser.add_argument('--config-profile', default=None,
344                         help=argparse.SUPPRESS)
345     parser.add_argument('--uninstall', action='store_true',
346                         help="Uninstall the server and all data")
347
348     args = vars(parser.parse_args())
349
350     if args['config_profile']:
351         args = parse_config_profile(args)
352
353     if len(args['hostname'].split('.')) < 2:
354         raise ValueError('Hostname: %s is not a FQDN.' % args['hostname'])
355
356     if args['port'] and not args['port'].isdigit():
357         raise ValueError('Port number: %s is not an integer.' % args['port'])
358
359     # Validate that all path options begin with '/'
360     path_args = ['saml_base', 'saml_auth', 'saml_sp', 'saml_sp_logout',
361                  'saml_sp_post']
362     for path_arg in path_args:
363         if not args[path_arg].startswith('/'):
364             raise ValueError('--%s must begin with a / character.' %
365                              path_arg.replace('_', '-'))
366
367     # The saml_sp setting must be a subpath of saml_base since it is
368     # used as the MellonEndpointPath.
369     if not args['saml_sp'].startswith(args['saml_base']):
370         raise ValueError('--saml-sp must be a subpath of --saml-base.')
371
372     # The saml_sp_logout and saml_sp_post settings must be subpaths
373     # of saml_sp (the mellon endpoint).
374     path_args = ['saml_sp_logout', 'saml_sp_post']
375     for path_arg in path_args:
376         if not args[path_arg].startswith(args['saml_sp']):
377             raise ValueError('--%s must be a subpath of --saml-sp' %
378                              path_arg.replace('_', '-'))
379
380     # If saml_idp_url if being used, we require saml_sp_name to
381     # use when registering the SP.
382     if args['saml_idp_url'] and not args['saml_sp_name']:
383         raise ValueError('--saml-sp-name must be specified when using' +
384                          '--saml-idp-url')
385
386     # At least one on this list needs to be specified or we do nothing
387     sp_list = ['saml']
388     present = False
389     for sp in sp_list:
390         if args[sp]:
391             present = True
392     if not present and not args['uninstall']:
393         raise ValueError('Nothing to install, please select a Service type.')
394
395
396 if __name__ == '__main__':
397     out = 0
398     openlogs()
399     try:
400         parse_args()
401
402         if 'uninstall' in args and args['uninstall'] is True:
403             uninstall()
404         else:
405             install()
406     except Exception, e:  # pylint: disable=broad-except
407         log_exception(e)
408         if 'uninstall' in args and args['uninstall'] is True:
409             logging.info('Uninstallation aborted.')
410         else:
411             logging.info('Installation aborted.')
412         out = 1
413     finally:
414         if out == 0:
415             if 'uninstall' in args and args['uninstall'] is True:
416                 logging.info('Uninstallation complete.')
417             else:
418                 logging.info('Installation complete.')
419     sys.exit(out)