]> vilimpoc.org git repositories - dotfiles/blame - xp-fetch.py
dotfiles: Windows XP VM provisioning
[dotfiles] / xp-fetch.py
CommitLineData
fc15a36e
MV
1#!/usr/bin/env python3
2"""Download over TLS 1.2 from a machine whose OS cannot.
3
4Windows XP tops out at TLS 1.0, but that limit lives in SChannel, not in the
5OS as such: any client carrying its own TLS stack is unaffected. CPython is one
6of those - it bundles OpenSSL - so the Python 3.4.4 that setup-windows-xp.bat
7installs is a perfectly capable TLS 1.2 client on a box where nothing else is.
8That is all this script is: urllib with the protocol floor raised and a CA
9bundle supplied from disk.
10
11The CA bundle is not optional. XP's root store predates most roots in use
12today, so certificate validation fails against it even when the handshake
13itself succeeds. cacert.pem next to this script - Mozilla's set, as published
14by the curl project - is what verification actually runs against. A curl or
15wget built for XP would need exactly the same thing passed to --cacert.
16
17Usage:
18 python xp-fetch.py URL [-o FILE]
19 python xp-fetch.py --tls-check HOST[:PORT]
20 python xp-fetch.py --tls-check pypi.org --verbose
21
22Written for Python 3.4 (the last release supporting XP) and still correct on
23current Pythons, so it can be tested on the host before it is trusted in the
24VM.
25"""
26
27import argparse
28import os
29import posixpath
30import socket
31import ssl
32import sys
33import urllib.error
34import urllib.request
35from urllib.parse import urlsplit
36
37# Ancient default User-Agents ("Python-urllib/3.4") are turned away by some
38# CDNs. Claiming to be a current curl is both honest about being a robot and
39# unlikely to be filtered.
40USER_AGENT = 'curl/8.0 (windows-xp; xp-fetch.py)'
41
42DEFAULT_CA = 'cacert.pem'
43CA_STALE_HINT = ('Refresh it on the host with:\n'
44 ' curl -o cacert.pem https://curl.se/ca/cacert.pem')
45
46
47def find_ca_bundle(explicit):
48 """Locate the CA bundle: --ca, then next to this script, then the cwd."""
49 if explicit:
50 if not os.path.isfile(explicit):
51 die('CA bundle not found: {0}'.format(explicit))
52 return explicit
53 here = os.path.dirname(os.path.abspath(__file__))
54 for candidate in (os.path.join(here, DEFAULT_CA), DEFAULT_CA):
55 if os.path.isfile(candidate):
56 return candidate
57 die('No {0} found next to {1} or in the current directory.\n{2}'
58 .format(DEFAULT_CA, os.path.basename(__file__), CA_STALE_HINT))
59
60
61def build_context(ca_bundle, insecure):
62 """An SSL context that refuses anything below TLS 1.2.
63
64 Nothing here is XP-specific - it is the same context you would want
65 anywhere. The reason it matters more here is that the fallbacks XP would
66 otherwise reach for are all dead protocols.
67 """
68 if insecure:
69 context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
70 context.check_hostname = False
71 context.verify_mode = ssl.CERT_NONE
72 else:
73 context = ssl.create_default_context(cafile=ca_bundle)
74
75 # TLSVersion arrived in 3.7; on 3.4 the OP_NO_* flags are the only lever.
76 minimum = getattr(ssl, 'TLSVersion', None)
77 if minimum is not None:
78 context.minimum_version = minimum.TLSv1_2
79 else:
80 for flag in ('OP_NO_SSLv2', 'OP_NO_SSLv3', 'OP_NO_TLSv1', 'OP_NO_TLSv1_1'):
81 context.options |= getattr(ssl, flag, 0)
82 return context
83
84
85def tls_check(target, ca_bundle, insecure, verbose):
86 """Handshake with a host and report what was negotiated.
87
88 Worth running first on any new box: it separates "my TLS stack is too old"
89 from "my certificate store is too old", which produce similar-looking
90 failures and have completely different fixes.
91 """
92 if ':' in target:
93 host, _, port = target.rpartition(':')
94 port = int(port)
95 else:
96 host, port = target, 443
97
98 context = build_context(ca_bundle, insecure)
99 raw = socket.create_connection((host, port), timeout=30)
100 try:
101 with context.wrap_socket(raw, server_hostname=host) as sock:
102 cipher = sock.cipher() or ('?', '?', 0)
103 print('host : {0}:{1}'.format(host, port))
104 print('protocol : {0}'.format(cipher[1]))
105 print('cipher : {0} ({1} bits)'.format(cipher[0], cipher[2]))
106 print('verified : {0}'.format('no (--insecure)' if insecure else 'yes'))
107 if verbose:
108 cert = sock.getpeercert() or {}
109 print('subject : {0}'.format(flatten_name(cert.get('subject'))))
110 print('issuer : {0}'.format(flatten_name(cert.get('issuer'))))
111 print('expires : {0}'.format(cert.get('notAfter', '?')))
112 print('openssl : {0}'.format(ssl.OPENSSL_VERSION))
113 finally:
114 raw.close()
115 return 0
116
117
118def flatten_name(rdns):
119 if not rdns:
120 return '?'
121 parts = []
122 for rdn in rdns:
123 for key, value in rdn:
124 parts.append('{0}={1}'.format(key, value))
125 return ', '.join(parts)
126
127
128def output_name(url, explicit):
129 if explicit:
130 return explicit
131 name = posixpath.basename(urlsplit(url).path)
132 return name or 'index.html'
133
134
135def fetch(url, dest, ca_bundle, insecure, verbose):
136 context = build_context(ca_bundle, insecure)
137 opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context))
138 request = urllib.request.Request(url, headers={'User-Agent': USER_AGENT})
139
140 response = opener.open(request, timeout=60)
141 try:
142 if verbose:
143 print('{0} {1}'.format(response.status if hasattr(response, 'status')
144 else response.getcode(), url), file=sys.stderr)
145 total = response.headers.get('Content-Length')
146 total = int(total) if total and total.isdigit() else None
147 written = 0
148 with open(dest, 'wb') as out:
149 while True:
150 chunk = response.read(64 * 1024)
151 if not chunk:
152 break
153 out.write(chunk)
154 written += len(chunk)
155 report(written, total)
156 finally:
157 response.close()
158 sys.stderr.write('\n')
159 print('{0} ({1} bytes)'.format(dest, written))
160
161 if total is not None and written != total:
162 die('Truncated: expected {0} bytes, got {1}.'.format(total, written), code=3)
163 return 0
164
165
166def report(written, total):
167 """One rewritten line of progress. XP consoles are slow; keep it cheap.
168
169 Only when stderr is a console: redirected to a file or a pipe, \r does not
170 rewrite anything and every tick lands as another line of noise in the log.
171 """
172 if not sys.stderr.isatty():
173 return
174 if total:
175 sys.stderr.write('\r {0:>10} / {1} bytes ({2:3.0f}%)'
176 .format(written, total, 100.0 * written / total))
177 else:
178 sys.stderr.write('\r {0:>10} bytes'.format(written))
179 sys.stderr.flush()
180
181
182def die(message, code=2):
183 sys.stderr.write('xp-fetch: {0}\n'.format(message))
184 raise SystemExit(code)
185
186
187def main(argv=None):
188 parser = argparse.ArgumentParser(
189 description='Fetch over TLS 1.2 on a host whose OS TLS stack cannot.')
190 parser.add_argument('url', nargs='?', help='URL to download')
191 parser.add_argument('-o', '--output', help='output file (default: name from the URL)')
192 parser.add_argument('--ca', help='CA bundle (default: cacert.pem beside this script)')
193 parser.add_argument('--tls-check', metavar='HOST[:PORT]',
194 help='handshake only, and report protocol and cipher')
195 parser.add_argument('--insecure', action='store_true',
196 help='skip certificate verification (diagnostics only)')
197 parser.add_argument('-v', '--verbose', action='store_true')
198 args = parser.parse_args(argv)
199
200 if not args.url and not args.tls_check:
201 parser.error('give a URL, or --tls-check HOST')
202
203 ca_bundle = None if args.insecure else find_ca_bundle(args.ca)
204 if args.verbose and ca_bundle:
205 print('ca bundle : {0}'.format(ca_bundle), file=sys.stderr)
206
207 try:
208 if args.tls_check:
209 return tls_check(args.tls_check, ca_bundle, args.insecure, args.verbose)
210 return fetch(args.url, output_name(args.url, args.output),
211 ca_bundle, args.insecure, args.verbose)
212 except ssl.SSLError as exc:
213 die('TLS failed: {0}\n'
214 'If this says certificate verify failed, the CA bundle is stale or wrong.\n{1}'
215 .format(exc, CA_STALE_HINT), code=4)
216 except (urllib.error.URLError, socket.error) as exc:
217 die('{0}'.format(exc), code=5)
218
219
220if __name__ == '__main__':
221 sys.exit(main())