b5b6ad1fb8dd52faaf3bc8ad5d4562b2f76cd5cb
[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     m.add_allowed_name_format(SAML2_NAMEID_MAP[args['saml_nameid']])
110     sp_metafile = os.path.join(path, 'metadata.xml')
111     m.output(sp_metafile)
112
113     if not args['saml_no_httpd']:
114         idp_metafile = os.path.join(path, 'idp-metadata.xml')
115         with open(idp_metafile, 'w+') as f:
116             f.write(idpmeta)
117
118         saml_protect = 'auth'
119         saml_auth=''
120         if args['saml_base'] != args['saml_auth']:
121             saml_protect = 'info'
122             saml_auth = '<Location %s>\n' \
123                         '    MellonEnable "auth"\n' \
124                         '    Header append Cache-Control "no-cache"\n' \
125                         '</Location>\n' % args['saml_auth']
126
127         psp = '# '
128         if args['saml_auth'] == SAML2_PROTECTED:
129             # default location, enable the default page
130             psp = ''
131
132         saml_secure = 'Off'
133         ssl_require = '#'
134         ssl_rewrite = '#'
135         if args['port']:
136             ssl_port = args['port']
137         else:
138             ssl_port = '443'
139
140         if args['saml_secure_setup']:
141             saml_secure = 'On'
142             ssl_require = ''
143             ssl_rewrite = ''
144
145         samlopts = {'saml_base': args['saml_base'],
146                     'saml_protect': saml_protect,
147                     'saml_sp_key': c.key,
148                     'saml_sp_cert': c.cert,
149                     'saml_sp_meta': sp_metafile,
150                     'saml_idp_meta': idp_metafile,
151                     'saml_sp': args['saml_sp'],
152                     'saml_secure_on': saml_secure,
153                     'saml_auth': saml_auth,
154                     'ssl_require': ssl_require,
155                     'ssl_rewrite': ssl_rewrite,
156                     'ssl_port': ssl_port,
157                     'sp_hostname': args['hostname'],
158                     'sp_port': port_str,
159                     'sp': psp}
160         files.write_from_template(SAML2_CONFFILE, SAML2_TEMPLATE, samlopts)
161
162         files.fix_user_dirs(SAML2_HTTPDIR, args['httpd_user'])
163
164         logger.info('SAML Service Provider configured.')
165         logger.info('You should be able to restart the HTTPD server and' +
166                     ' then access it at %s%s' % (url, args['saml_auth']))
167     else:
168         logger.info('SAML Service Provider configuration ready.')
169         logger.info('Use the certificate, key and metadata.xml files to' +
170                     ' configure your Service Provider')
171
172
173 def install():
174     if args['saml']:
175         saml2()
176
177
178 def saml2_uninstall():
179     try:
180         shutil.rmtree(os.path.join(SAML2_HTTPDIR, args['hostname']))
181     except Exception, e:  # pylint: disable=broad-except
182         log_exception(e)
183     try:
184         os.remove(SAML2_CONFFILE)
185     except Exception, e:  # pylint: disable=broad-except
186         log_exception(e)
187
188
189 def uninstall():
190     logger.info('Uninstalling Service Provider')
191     #FXIME: ask confirmation
192     saml2_uninstall()
193     logger.info('Uninstalled SAML2 data')
194
195
196 def log_exception(e):
197     if 'debug' in args and args['debug']:
198         logger.exception(e)
199     else:
200         logger.error(e)
201
202
203 def parse_config_profile(args):
204     config = ConfigParser.ConfigParser()
205     files = config.read(args['config_profile'])
206     if len(files) == 0:
207         raise ConfigurationError('Config Profile file %s not found!' %
208                                  args['config_profile'])
209
210     if 'globals' in config.sections():
211         G = config.options('globals')
212         for g in G:
213             val = config.get('globals', g)
214             if val == 'False':
215                 val = False
216             elif val == 'True':
217                 val = True
218             if g in globals():
219                 globals()[g] = val
220             else:
221                 for k in globals().keys():
222                     if k.lower() == g.lower():
223                         globals()[k] = val
224                         break
225
226     if 'arguments' in config.sections():
227         A = config.options('arguments')
228         for a in A:
229             val = config.get('arguments', a)
230             if val == 'False':
231                 val = False
232             elif val == 'True':
233                 val = True
234             args[a] = val
235
236     return args
237
238
239 def parse_args():
240     global args
241
242     fc = argparse.ArgumentDefaultsHelpFormatter
243     parser = argparse.ArgumentParser(description='Client Install Options',
244                                      formatter_class=fc)
245     parser.add_argument('--version',
246                         action='version', version='%(prog)s 0.1')
247     parser.add_argument('--hostname', default=socket.getfqdn(),
248                         help="Machine's fully qualified host name")
249     parser.add_argument('--port', default=None,
250                         help="Port number that SP listens on")
251     parser.add_argument('--admin-user', default='admin',
252                         help="Account allowed to create a SP")
253     parser.add_argument('--httpd-user', default='apache',
254                         help="Web server account used to read certs")
255     parser.add_argument('--saml', action='store_true', default=True,
256                         help="Whether to install a saml2 SP")
257     parser.add_argument('--saml-idp-metadata', default=None,
258                         help="A URL pointing at the IDP Metadata (FILE or HTTP)")
259     parser.add_argument('--saml-no-httpd', action='store_true', default=False,
260                         help="Do not configure httpd")
261     parser.add_argument('--saml-base', default='/',
262                         help="Where saml2 authdata is available")
263     parser.add_argument('--saml-auth', default=SAML2_PROTECTED,
264                         help="Where saml2 authentication is enforced")
265     parser.add_argument('--saml-sp', default='/saml2',
266                         help="Where saml communication happens")
267     parser.add_argument('--saml-sp-logout', default='/saml2/logout',
268                         help="Single Logout URL")
269     parser.add_argument('--saml-sp-post', default='/saml2/postResponse',
270                         help="Post response URL")
271     parser.add_argument('--saml-secure-setup', action='store_true',
272                         default=True, help="Turn on all security checks")
273     parser.add_argument('--saml-nameid', default='unspecified',
274                         choices=SAML2_NAMEID_MAP.keys(),
275                         help="SAML NameID format to use")
276     parser.add_argument('--debug', action='store_true', default=False,
277                         help="Turn on script debugging")
278     parser.add_argument('--config-profile', default=None,
279                         help="File containing install options")
280     parser.add_argument('--uninstall', action='store_true',
281                         help="Uninstall the server and all data")
282
283     args = vars(parser.parse_args())
284
285     if args['config_profile']:
286         args = parse_config_profile(args)
287
288     if len(args['hostname'].split('.')) < 2:
289         raise ValueError('Hostname: %s is not a FQDN.' % args['hostname'])
290
291     if args['port'] and not args['port'].isdigit():
292         raise ValueError('Port number: %s is not an integer.' % args['port'])
293
294     # Validate that all path options begin with '/'
295     path_args = ['saml_base', 'saml_auth', 'saml_sp', 'saml_sp_logout',
296                  'saml_sp_post']
297     for path_arg in path_args:
298         if not args[path_arg].startswith('/'):
299             raise ValueError('--%s must begin with a / character.' %
300                              path_arg.replace('_', '-'))
301
302     # The saml_sp setting must be a subpath of saml_base since it is
303     # used as the MellonEndpointPath.
304     if not args['saml_sp'].startswith(args['saml_base']):
305         raise ValueError('--saml-sp must be a subpath of --saml-base.')
306
307     # The saml_sp_logout and saml_sp_post settings must be subpaths
308     # of saml_sp (the mellon endpoint).
309     path_args = ['saml_sp_logout', 'saml_sp_post']
310     for path_arg in path_args:
311         if not args[path_arg].startswith(args['saml_sp']):
312             raise ValueError('--%s must be a subpath of --saml-sp' %
313                              path_arg.replace('_', '-'))
314
315     # At least one on this list needs to be specified or we do nothing
316     sp_list = ['saml']
317     present = False
318     for sp in sp_list:
319         if args[sp]:
320             present = True
321     if not present and not args['uninstall']:
322         raise ValueError('Nothing to install, please select a Service type.')
323
324
325 if __name__ == '__main__':
326     out = 0
327     openlogs()
328     try:
329         parse_args()
330
331         if 'uninstall' in args and args['uninstall'] is True:
332             uninstall()
333         else:
334             install()
335     except Exception, e:  # pylint: disable=broad-except
336         log_exception(e)
337         if 'uninstall' in args and args['uninstall'] is True:
338             print 'Uninstallation aborted.'
339         else:
340             print 'Installation aborted.'
341         out = 1
342     finally:
343         if out == 0:
344             if 'uninstall' in args and args['uninstall'] is True:
345                 print 'Uninstallation complete.'
346             else:
347                 print 'Installation complete.'
348     sys.exit(out)