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