23909923e2c860abcdfa3e8f0a16c54e15167515
[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 import argparse
26 import ConfigParser
27 import logging
28 import os
29 import pwd
30 import requests
31 import shutil
32 import socket
33 import sys
34
35
36 HTTPDCONFD = '/etc/httpd/conf.d'
37 SAML2_TEMPLATE = '/usr/share/ipsilon/templates/install/saml2/sp.conf'
38 SAML2_CONFFILE = '/etc/httpd/conf.d/ipsilon-saml.conf'
39 SAML2_HTTPDIR = '/etc/httpd/saml2'
40 SAML2_PROTECTED = '/saml2protected'
41
42 #Installation arguments
43 args = dict()
44
45 # Regular logging
46 logger = logging.getLogger()
47
48
49 def openlogs():
50     global logger  # pylint: disable=W0603
51     logger = logging.getLogger()
52     lh = logging.StreamHandler(sys.stderr)
53     logger.addHandler(lh)
54
55
56 def saml2():
57     logger.info('Installing SAML2 Service Provider')
58
59     if args['saml_idp_metadata'] is None:
60         #TODO: detect via SRV records ?
61         raise ValueError('An IDP metadata file/url is required.')
62
63     idpmeta = None
64
65     try:
66         if os.path.exists(args['saml_idp_metadata']):
67             with open(args['saml_idp_metadata']) as f:
68                 idpmeta = f.read()
69         elif args['saml_idp_metadata'].startswith('file://'):
70             with open(args['saml_idp_metadata'][7:]) as f:
71                 idpmeta = f.read()
72         else:
73             r = requests.get(args['saml_idp_metadata'])
74             r.raise_for_status()
75             idpmeta = r.content
76     except Exception, e:  # pylint: disable=broad-except
77         logger.error("Failed to retrieve IDP Metadata file!\n" +
78                      "Error: [%s]" % repr(e))
79         raise
80
81     path = None
82     if not args['saml_no_httpd']:
83         path = os.path.join(SAML2_HTTPDIR, args['hostname'])
84         os.makedirs(path, 0750)
85     else:
86         path = os.getcwd()
87
88     proto = 'https'
89     if not args['saml_secure_setup']:
90         proto = 'http'
91
92     port_str = ''
93     if args['port']:
94         port_str = ':%s' % args['port']
95
96     url = '%s://%s%s' % (proto, args['hostname'], port_str)
97     url_sp = url + args['saml_sp']
98     url_logout = url + args['saml_sp_logout']
99     url_post = url + args['saml_sp_post']
100
101     # Generate metadata
102     m = Metadata('sp')
103     c = Certificate(path)
104     c.generate('certificate', args['hostname'])
105     m.set_entity_id(url_sp)
106     m.add_certs(c)
107     m.add_service(SAML2_SERVICE_MAP['logout-redirect'], url_logout)
108     m.add_service(SAML2_SERVICE_MAP['response-post'], url_post, index="0")
109     sp_metafile = os.path.join(path, 'metadata.xml')
110     m.output(sp_metafile)
111
112     if not args['saml_no_httpd']:
113         idp_metafile = os.path.join(path, 'idp-metadata.xml')
114         with open(idp_metafile, 'w+') as f:
115             f.write(idpmeta)
116
117         saml_protect = 'auth'
118         saml_auth=''
119         if args['saml_base'] != args['saml_auth']:
120             saml_protect = 'info'
121             saml_auth = '<Location %s>\n' \
122                         '    MellonEnable "auth"\n' \
123                         '    Header append Cache-Control "no-cache"\n' \
124                         '</Location>\n' % args['saml_auth']
125
126         psp = '# '
127         if args['saml_auth'] == SAML2_PROTECTED:
128             # default location, enable the default page
129             psp = ''
130
131         saml_secure = 'Off'
132         ssl_require = '#'
133         ssl_rewrite = '#'
134         if args['port']:
135             ssl_port = args['port']
136         else:
137             ssl_port = '443'
138
139         if args['saml_secure_setup']:
140             saml_secure = 'On'
141             ssl_require = ''
142             ssl_rewrite = ''
143
144         samlopts = {'saml_base': args['saml_base'],
145                     'saml_protect': saml_protect,
146                     'saml_sp_key': c.key,
147                     'saml_sp_cert': c.cert,
148                     'saml_sp_meta': sp_metafile,
149                     'saml_idp_meta': idp_metafile,
150                     'saml_sp': args['saml_sp'],
151                     'saml_secure_on': saml_secure,
152                     'saml_auth': saml_auth,
153                     'ssl_require': ssl_require,
154                     'ssl_rewrite': ssl_rewrite,
155                     'ssl_port': ssl_port,
156                     'sp_hostname': args['hostname'],
157                     'sp_port': port_str,
158                     'sp': psp}
159         files.write_from_template(SAML2_CONFFILE, SAML2_TEMPLATE, samlopts)
160
161         files.fix_user_dirs(SAML2_HTTPDIR, args['httpd_user'])
162
163         logger.info('SAML Service Provider configured.')
164         logger.info('You should be able to restart the HTTPD server and' +
165                     ' then access it at %s%s' % (url, args['saml_auth']))
166     else:
167         logger.info('SAML Service Provider configuration ready.')
168         logger.info('Use the certificate, key and metadata.xml files to' +
169                     ' configure your Service Provider')
170
171
172 def install():
173     if args['saml']:
174         saml2()
175
176
177 def saml2_uninstall():
178     try:
179         shutil.rmtree(os.path.join(SAML2_HTTPDIR, args['hostname']))
180     except Exception, e:  # pylint: disable=broad-except
181         log_exception(e)
182     try:
183         os.remove(SAML2_CONFFILE)
184     except Exception, e:  # pylint: disable=broad-except
185         log_exception(e)
186
187
188 def uninstall():
189     logger.info('Uninstalling Service Provider')
190     #FXIME: ask confirmation
191     saml2_uninstall()
192     logger.info('Uninstalled SAML2 data')
193
194
195 def log_exception(e):
196     if 'debug' in args and args['debug']:
197         logger.exception(e)
198     else:
199         logger.error(e)
200
201
202 def parse_config_profile(args):
203     config = ConfigParser.ConfigParser()
204     files = config.read(args['config_profile'])
205     if len(files) == 0:
206         raise ConfigurationError('Config Profile file %s not found!' %
207                                  args['config_profile'])
208
209     if 'globals' in config.sections():
210         G = config.options('globals')
211         for g in G:
212             val = config.get('globals', g)
213             if val == 'False':
214                 val = False
215             elif val == 'True':
216                 val = True
217             if g in globals():
218                 globals()[g] = val
219             else:
220                 for k in globals().keys():
221                     if k.lower() == g.lower():
222                         globals()[k] = val
223                         break
224
225     if 'arguments' in config.sections():
226         A = config.options('arguments')
227         for a in A:
228             val = config.get('arguments', a)
229             if val == 'False':
230                 val = False
231             elif val == 'True':
232                 val = True
233             args[a] = val
234
235     return args
236
237
238 def parse_args():
239     global args
240
241     fc = argparse.ArgumentDefaultsHelpFormatter
242     parser = argparse.ArgumentParser(description='Client Install Options',
243                                      formatter_class=fc)
244     parser.add_argument('--version',
245                         action='version', version='%(prog)s 0.1')
246     parser.add_argument('--hostname', default=socket.getfqdn(),
247                         help="Machine's fully qualified host name")
248     parser.add_argument('--port', default=None,
249                         help="Port number that SP listens on")
250     parser.add_argument('--admin-user', default='admin',
251                         help="Account allowed to create a SP")
252     parser.add_argument('--httpd-user', default='apache',
253                         help="Web server account used to read certs")
254     parser.add_argument('--saml', action='store_true', default=True,
255                         help="Whether to install a saml2 SP")
256     parser.add_argument('--saml-idp-metadata', default=None,
257                         help="A URL pointing at the IDP Metadata (FILE or HTTP)")
258     parser.add_argument('--saml-no-httpd', action='store_true', default=False,
259                         help="Do not configure httpd")
260     parser.add_argument('--saml-base', default='/',
261                         help="Where saml2 authdata is available")
262     parser.add_argument('--saml-auth', default=SAML2_PROTECTED,
263                         help="Where saml2 authentication is enforced")
264     parser.add_argument('--saml-sp', default='/saml2',
265                         help="Where saml communication happens")
266     parser.add_argument('--saml-sp-logout', default='/saml2/logout',
267                         help="Single Logout URL")
268     parser.add_argument('--saml-sp-post', default='/saml2/postResponse',
269                         help="Post response URL")
270     parser.add_argument('--saml-secure-setup', action='store_true',
271                         default=True, help="Turn on all security checks")
272     parser.add_argument('--debug', action='store_true', default=False,
273                         help="Turn on script debugging")
274     parser.add_argument('--config-profile', default=None,
275                         help="File containing install options")
276     parser.add_argument('--uninstall', action='store_true',
277                         help="Uninstall the server and all data")
278
279     args = vars(parser.parse_args())
280
281     if args['config_profile']:
282         args = parse_config_profile(args)
283
284     if len(args['hostname'].split('.')) < 2:
285         raise ValueError('Hostname: %s is not a FQDN.' % args['hostname'])
286
287     if args['port'] and not args['port'].isdigit():
288         raise ValueError('Port number: %s is not an integer.' % args['port'])
289
290     # Validate that all path options begin with '/'
291     path_args = ['saml_base', 'saml_auth', 'saml_sp', 'saml_sp_logout',
292                  'saml_sp_post']
293     for path_arg in path_args:
294         if not args[path_arg].startswith('/'):
295             raise ValueError('--%s must begin with a / character.' %
296                              path_arg.replace('_', '-'))
297
298     # The saml_sp setting must be a subpath of saml_base since it is
299     # used as the MellonEndpointPath.
300     if not args['saml_sp'].startswith(args['saml_base']):
301         raise ValueError('--saml-sp must be a subpath of --saml-base.')
302
303     # The saml_sp_logout and saml_sp_post settings must be subpaths
304     # of saml_sp (the mellon endpoint).
305     path_args = ['saml_sp_logout', 'saml_sp_post']
306     for path_arg in path_args:
307         if not args[path_arg].startswith(args['saml_sp']):
308             raise ValueError('--%s must be a subpath of --saml-sp' %
309                              path_arg.replace('_', '-'))
310
311     # At least one on this list needs to be specified or we do nothing
312     sp_list = ['saml']
313     present = False
314     for sp in sp_list:
315         if args[sp]:
316             present = True
317     if not present and not args['uninstall']:
318         raise ValueError('Nothing to install, please select a Service type.')
319
320
321 if __name__ == '__main__':
322     out = 0
323     openlogs()
324     try:
325         parse_args()
326
327         if 'uninstall' in args and args['uninstall'] is True:
328             uninstall()
329
330         install()
331     except Exception, e:  # pylint: disable=broad-except
332         log_exception(e)
333         if 'uninstall' in args and args['uninstall'] is True:
334             print 'Uninstallation aborted.'
335         else:
336             print 'Installation aborted.'
337         out = 1
338     finally:
339         if out == 0:
340             if 'uninstall' in args and args['uninstall'] is True:
341                 print 'Uninstallation complete.'
342             else:
343                 print 'Installation complete.'
344     sys.exit(out)