Proxy handling now detects need for updated credentials.

This revamps the way proxy handling is done to allow us to just proceed as
normal, and then if we get HTTP errors that indicate that we need a proxy or
need proxy credentials, we ask for them and then retry everything.

We still try to auto-detect the need for a proxy on our very first invocation
because on the first invocation, a failure to fetch a URL may well indicate
that a proxy is needed, but that same assumption does not hold on subsequent
invocations. In those later cases, it's probably just a transient network
failure and it would be weird and annoying to pop up the "please provide proxy
config" dialog in those cases.

Thanks pb00068 for getting this ball rolling, even though once again I have
nearly entirely rewritten the PR. I need to kick that habit. :)
This commit is contained in:
Michael Bayne
2019-05-28 17:21:59 -07:00
parent e337ccb743
commit 0a4bfe65c4
14 changed files with 481 additions and 380 deletions
@@ -7,7 +7,6 @@ package com.threerings.getdown.data;
import java.io.*;
import java.lang.reflect.Method;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.Proxy;
import java.net.URL;
@@ -23,8 +22,8 @@ import java.util.*;
import java.util.concurrent.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;
import com.threerings.getdown.net.Connector;
import com.threerings.getdown.util.*;
// avoid ambiguity with java.util.Base64 which we can't use as it's 1.8+
import com.threerings.getdown.util.Base64;
@@ -271,10 +270,10 @@ public class Application
return config;
}
/** The proxy that should be used to do HTTP downloads. This must be configured prior to using
* the application instance. Yes this is a public mutable field, no I'm not going to create a
/** A helper that is used to do HTTP downloads. This must be configured prior to using the
* application instance. Yes this is a public mutable field, no I'm not going to create a
* getter and setter just to pretend like that's not the case. */
public Proxy proxy = Proxy.NO_PROXY;
public Connector conn = Connector.DEFAULT;
/**
* Creates an application instance which records the location of the {@code getdown.txt}
@@ -989,16 +988,8 @@ public class Application
args.add("-Xdock:name=" + _dockName);
}
// pass along our proxy settings
if (proxy.type() == Proxy.Type.HTTP && proxy.address() instanceof InetSocketAddress) {
InetSocketAddress proxyAddr = (InetSocketAddress) proxy.address();
String proxyHost = proxyAddr.getHostString();
int proxyPort = proxyAddr.getPort();
args.add("-Dhttp.proxyHost=" + proxyHost);
args.add("-Dhttp.proxyPort=" + proxyPort);
args.add("-Dhttps.proxyHost=" + proxyHost);
args.add("-Dhttps.proxyPort=" + proxyPort);
}
// forward our proxy settings
conn.addProxyArgs(args);
// add the marker indicating the app is running in getdown
args.add("-D" + Properties.GETDOWN + "=true");
@@ -1258,10 +1249,10 @@ public class Application
}
if (_latest != null) {
try (InputStream in = ConnectionUtil.open(proxy, _latest, 0, 0).getInputStream();
InputStreamReader reader = new InputStreamReader(in, UTF_8);
BufferedReader bin = new BufferedReader(reader)) {
for (String[] pair : Config.parsePairs(bin, Config.createOpts(false))) {
try {
List<String[]> vdata = Config.parsePairs(
new StringReader(conn.fetch(_latest)), Config.createOpts(false));
for (String[] pair : vdata) {
if ("version".equals(pair[0])) {
_targetVersion = Math.max(Long.parseLong(pair[1]), _targetVersion);
if (fileVersion != -1 && _targetVersion > fileVersion) {
@@ -1485,8 +1476,7 @@ public class Application
/**
* Downloads a new copy of CONFIG_FILE.
*/
protected void downloadConfigFile ()
throws IOException
protected void downloadConfigFile () throws IOException
{
downloadControlFile(CONFIG_FILE, 0);
}
@@ -1628,8 +1618,7 @@ public class Application
* Download a path to a temporary file, returning a {@link File} instance with the path
* contents.
*/
protected File downloadFile (String path)
throws IOException
protected File downloadFile (String path) throws IOException
{
File target = getLocalPath(path + "_new");
@@ -1643,26 +1632,7 @@ public class Application
}
log.info("Attempting to refetch '" + path + "' from '" + targetURL + "'.");
// stream the URL into our temporary file
URLConnection uconn = ConnectionUtil.open(proxy, targetURL, 0, 0);
// we have to tell Java not to use caches here, otherwise it will cache any request for
// same URL for the lifetime of this JVM (based on the URL string, not the URL object);
// if the getdown.txt file, for example, changes in the meanwhile, we would never hear
// about it; turning off caches is not a performance concern, because when Getdown asks
// to download a file, it expects it to come over the wire, not from a cache
uconn.setUseCaches(false);
uconn.setRequestProperty("Accept-Encoding", "gzip");
try (InputStream fin = uconn.getInputStream()) {
String encoding = uconn.getContentEncoding();
boolean gzip = "gzip".equalsIgnoreCase(encoding);
try (InputStream fin2 = (gzip ? new GZIPInputStream(fin) : fin)) {
try (FileOutputStream fout = new FileOutputStream(target)) {
StreamUtil.copy(fin2, fout);
}
}
}
conn.download(targetURL, target); // stream the URL into our temporary file
return target;
}
@@ -101,6 +101,16 @@ public final class SysProps
return Boolean.getBoolean("direct");
}
/** If true, Getdown will always try to connect without proxy settings even it a proxy is set
* in {@code proxy.txt}. If direct access is possible it will not clear {@code proxy.txt}, it
* will preserve the settings. This is to support cases where a user uses a workstation in two
* different networks, one with proxy the other one without. They should not be asked for
* proxy settings again each time they switch back to the proxy network.
* Usage: {@code -Dtry_no_proxy}. */
public static boolean tryNoProxyFirst () {
return Boolean.getBoolean("try_no_proxy");
}
/** Specifies the connection timeout (in seconds) to use when downloading control files from
* the server. This is chiefly useful when you are running in versionless mode and want Getdown
* to more quickly timeout its startup update check if the server with which it is
@@ -0,0 +1,182 @@
//
// Getdown - application installer, patcher and launcher
// Copyright (C) 2004-2018 Getdown authors
// https://github.com/threerings/getdown/blob/master/LICENSE
package com.threerings.getdown.net;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLDecoder;
import java.util.List;
import java.util.zip.GZIPInputStream;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.threerings.getdown.data.SysProps;
import com.threerings.getdown.util.Base64;
import com.threerings.getdown.util.StreamUtil;
/**
* Manages the process of making HTTP connections, using a proxy if necessary. Tracks and reports
* when proxy credentials are rejected.
*/
public class Connector {
/** The default connector uses no proxy. */
public static final Connector DEFAULT = new Connector(Proxy.NO_PROXY);
/** Tracks the state of a connector. If it fails for proxy-related reasons, it may transition
* to a need_proxy or need_proxy_auth state. */
public enum State { ACTIVE, NEED_PROXY, NEED_PROXY_AUTH }
/** The proxy used by this connector. */
public final Proxy proxy;
/** The current state of this connector. */
public State state = State.ACTIVE;
public Connector (Proxy proxy) {
this.proxy = proxy;
}
/**
* Opens a connection to a URL, setting the authentication header if user info is present.
* @param proxy the proxy via which to perform HTTP connections.
* @param url the URL to which to open a connection.
* @param connectTimeout if {@code > 0} then a timeout, in seconds, to use when opening the
* connection. If {@code 0} is supplied, the connection timeout specified via system properties
* will be used instead.
* @param readTimeout if {@code > 0} then a timeout, in seconds, to use while reading data from
* the connection. If {@code 0} is supplied, the read timeout specified via system properties
* will be used instead.
*/
public URLConnection open (URL url, int connectTimeout, int readTimeout)
throws IOException
{
URLConnection conn = url.openConnection(proxy);
// configure a connect timeout, if requested
int ctimeout = connectTimeout > 0 ? connectTimeout : SysProps.connectTimeout();
if (ctimeout > 0) {
conn.setConnectTimeout(ctimeout * 1000);
}
// configure a read timeout, if requested
int rtimeout = readTimeout > 0 ? readTimeout : SysProps.readTimeout();
if (rtimeout > 0) {
conn.setReadTimeout(rtimeout * 1000);
}
// If URL has a username:password@ before hostname, use HTTP basic auth
String userInfo = url.getUserInfo();
if (userInfo != null) {
// Remove any percent-encoding in the username/password
userInfo = URLDecoder.decode(userInfo, "UTF-8");
// Now base64 encode the auth info and make it a single line
String encoded = Base64.encodeToString(userInfo.getBytes(UTF_8), Base64.DEFAULT).
replaceAll("\\n","").replaceAll("\\r", "");
conn.setRequestProperty("Authorization", "Basic " + encoded);
}
return conn;
}
/**
* Opens a connection to a http or https URL, setting the authentication header if user info is
* present. Throws a class cast exception if the connection returned is not the right type. See
* {@link #open} for parameter documentation.
*/
public HttpURLConnection openHttp (URL url, int connectTimeout, int readTimeout)
throws IOException
{
return (HttpURLConnection)open(url, connectTimeout, readTimeout);
}
/**
* Downloads {@code url} into {@code target}.
*/
public void download (URL url, File target) throws IOException {
URLConnection conn = open(url, 0, 0);
// we have to tell Java not to use caches here, otherwise it will cache any request for
// same URL for the lifetime of this JVM (based on the URL string, not the URL object);
// if the getdown.txt file, for example, changes in the meanwhile, we would never hear
// about it; turning off caches is not a performance concern, because when Getdown asks
// to download a file, it expects it to come over the wire, not from a cache
conn.setUseCaches(false);
conn.setRequestProperty("Accept-Encoding", "gzip");
checkConnectOK(conn, "Unable to download " + url);
try (InputStream fin = conn.getInputStream()) {
String encoding = conn.getContentEncoding();
boolean gzip = "gzip".equalsIgnoreCase(encoding);
try (InputStream fin2 = (gzip ? new GZIPInputStream(fin) : fin)) {
try (FileOutputStream fout = new FileOutputStream(target)) {
StreamUtil.copy(fin2, fout);
}
}
}
}
/**
* Fetches the data at {@code url} into a string.
*/
public String fetch (URL url) throws IOException {
URLConnection conn = open(url, 0, 0);
checkConnectOK(conn, "Unable to fetch " + url);
int size = conn.getContentLength();
ByteArrayOutputStream out = new ByteArrayOutputStream(size > 0 ? size : 1024);
try (InputStream in = conn.getInputStream()) {
StreamUtil.copy(in, out);
}
return out.toString(UTF_8.toString());
}
/**
* Checks that {@code conn} returned an {@code OK} response code iff it is an HTTP connection.
* If the connection failed for proxy related reasons, this changes the state of this connector
* to reflect the needed proxy information.
*/
public void checkConnectOK (URLConnection conn, String errpre) throws IOException
{
// if it's not an HTTP connection, there's nothing to check
if (!(conn instanceof HttpURLConnection)) return;
int code = ((HttpURLConnection)conn).getResponseCode();
switch (code) {
case HttpURLConnection.HTTP_OK:
return;
case HttpURLConnection.HTTP_FORBIDDEN:
case HttpURLConnection.HTTP_USE_PROXY:
state = State.NEED_PROXY;
break;
case HttpURLConnection.HTTP_PROXY_AUTH:
state = State.NEED_PROXY_AUTH;
break;
}
throw new IOException(errpre + " [code=" + code + "]");
}
/**
* Adds appropriate proxy args from this connector's configuration to the supplied list of
* command line args that will be used to launch the app.
*/
public void addProxyArgs (List<String> args) {
if (proxy.type() == Proxy.Type.HTTP && proxy.address() instanceof InetSocketAddress) {
InetSocketAddress proxyAddr = (InetSocketAddress) proxy.address();
String proxyHost = proxyAddr.getHostString();
int proxyPort = proxyAddr.getPort();
args.add("-Dhttp.proxyHost=" + proxyHost);
args.add("-Dhttp.proxyPort=" + proxyPort);
args.add("-Dhttps.proxyHost=" + proxyHost);
args.add("-Dhttps.proxyPort=" + proxyPort);
}
}
}
@@ -6,7 +6,13 @@
package com.threerings.getdown.net;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URLConnection;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.util.Collection;
import java.util.HashMap;
@@ -26,8 +32,13 @@ import static com.threerings.getdown.Log.log;
* implementors must take care to only execute thread-safe code or simply pass a message to the AWT
* thread, for example.
*/
public abstract class Downloader
public class Downloader
{
public Downloader (Connector conn)
{
_conn = conn;
}
/**
* Start the downloading process.
* @param resources the resources to download.
@@ -132,7 +143,22 @@ public abstract class Downloader
/**
* Performs the protocol-specific portion of checking download size.
*/
protected abstract long checkSize (Resource rsrc) throws IOException;
protected long checkSize (Resource rsrc) throws IOException {
URLConnection conn = _conn.open(rsrc.getRemote(), 0, 0);
try {
// if we're accessing our data via HTTP, we only need a HEAD request
if (conn instanceof HttpURLConnection) {
((HttpURLConnection)conn).setRequestMethod("HEAD");
}
// make sure we got a satisfactory response code
_conn.checkConnectOK(conn, "Unable to check up-to-date for " + rsrc.getRemote());
return conn.getContentLength();
} finally {
// let it be known that we're done with this connection
conn.getInputStream().close();
}
}
/**
* Periodically called by the protocol-specific downloaders to update their progress. This
@@ -203,13 +229,62 @@ public abstract class Downloader
* protocol-specific code. This method should periodically check whether {@code _state} is set
* to aborted and abort any in-progress download if so.
*/
protected abstract void download (Resource rsrc) throws IOException;
protected void download (Resource rsrc) throws IOException {
URLConnection conn = _conn.open(rsrc.getRemote(), 0, 0);
// make sure we got a satisfactory response code
_conn.checkConnectOK(conn, "Unable to download resource " + rsrc.getRemote());
// TODO: make FileChannel download impl (below) robust and allow apps to opt-into it via a
// system property
if (true) {
// download the resource from the specified URL
long actualSize = conn.getContentLength();
log.info("Downloading resource", "url", rsrc.getRemote(), "size", actualSize);
long currentSize = 0L;
byte[] buffer = new byte[4*4096];
try (InputStream in = conn.getInputStream();
FileOutputStream out = new FileOutputStream(rsrc.getLocalNew())) {
// TODO: look to see if we have a download info file
// containing info on potentially partially downloaded data;
// if so, use a "Range: bytes=HAVE-" header.
// read in the file data
int read;
while ((read = in.read(buffer)) != -1) {
// abort the download if the downloader is aborted
if (_state == State.ABORTED) {
break;
}
// write it out to our local copy
out.write(buffer, 0, read);
// note that we've downloaded some data
currentSize += read;
reportProgress(rsrc, currentSize, actualSize);
}
}
} else {
log.info("Downloading resource", "url", rsrc.getRemote(), "size", "unknown");
File localNew = rsrc.getLocalNew();
try (ReadableByteChannel rbc = Channels.newChannel(conn.getInputStream());
FileOutputStream fos = new FileOutputStream(localNew)) {
// TODO: more work is needed here, transferFrom can fail to transfer the entire
// file, in which case it's not clear what we're supposed to do.. call it again?
// will it repeatedly fail?
fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
reportProgress(rsrc, localNew.length(), localNew.length());
}
}
}
protected final Connector _conn;
/** The reported sizes of our resources. */
protected Map<Resource, Long> _sizes = new HashMap<>();
protected final Map<Resource, Long> _sizes = new HashMap<>();
/** The bytes downloaded for each resource. */
protected Map<Resource, Long> _downloaded = new HashMap<>();
protected final Map<Resource, Long> _downloaded = new HashMap<>();
/** The time at which the file transfer began. */
protected long _start;
@@ -1,115 +0,0 @@
//
// Getdown - application installer, patcher and launcher
// Copyright (C) 2004-2018 Getdown authors
// https://github.com/threerings/getdown/blob/master/LICENSE
package com.threerings.getdown.net;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.Proxy;
import java.net.URL;
import java.net.URLConnection;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import com.threerings.getdown.data.Resource;
import com.threerings.getdown.util.ConnectionUtil;
import static com.threerings.getdown.Log.log;
/**
* Implements downloading files over HTTP
*/
public class HTTPDownloader extends Downloader
{
public HTTPDownloader (Proxy proxy)
{
_proxy = proxy;
}
@Override protected long checkSize (Resource rsrc) throws IOException
{
URLConnection conn = ConnectionUtil.open(_proxy, rsrc.getRemote(), 0, 0);
try {
// if we're accessing our data via HTTP, we only need a HEAD request
if (conn instanceof HttpURLConnection) {
HttpURLConnection hcon = (HttpURLConnection)conn;
hcon.setRequestMethod("HEAD");
hcon.connect();
// make sure we got a satisfactory response code
if (hcon.getResponseCode() != HttpURLConnection.HTTP_OK) {
throw new IOException("Unable to check up-to-date for " +
rsrc.getRemote() + ": " + hcon.getResponseCode());
}
}
return conn.getContentLength();
} finally {
// let it be known that we're done with this connection
conn.getInputStream().close();
}
}
@Override protected void download (Resource rsrc) throws IOException
{
// TODO: make FileChannel download impl (below) robust and allow apps to opt-into it via a
// system property
if (true) {
// download the resource from the specified URL
URLConnection conn = ConnectionUtil.open(_proxy, rsrc.getRemote(), 0, 0);
conn.connect();
// make sure we got a satisfactory response code
if (conn instanceof HttpURLConnection) {
HttpURLConnection hcon = (HttpURLConnection)conn;
if (hcon.getResponseCode() != HttpURLConnection.HTTP_OK) {
throw new IOException("Unable to download resource " + rsrc.getRemote() + ": " +
hcon.getResponseCode());
}
}
long actualSize = conn.getContentLength();
log.info("Downloading resource", "url", rsrc.getRemote(), "size", actualSize);
long currentSize = 0L;
byte[] buffer = new byte[4*4096];
try (InputStream in = conn.getInputStream();
FileOutputStream out = new FileOutputStream(rsrc.getLocalNew())) {
// TODO: look to see if we have a download info file
// containing info on potentially partially downloaded data;
// if so, use a "Range: bytes=HAVE-" header.
// read in the file data
int read;
while ((read = in.read(buffer)) != -1) {
// abort the download if the downloader is aborted
if (_state == State.ABORTED) {
break;
}
// write it out to our local copy
out.write(buffer, 0, read);
// note that we've downloaded some data
currentSize += read;
reportProgress(rsrc, currentSize, actualSize);
}
}
} else {
log.info("Downloading resource", "url", rsrc.getRemote(), "size", "unknown");
File localNew = rsrc.getLocalNew();
try (ReadableByteChannel rbc = Channels.newChannel(rsrc.getRemote().openStream());
FileOutputStream fos = new FileOutputStream(localNew)) {
// TODO: more work is needed here, transferFrom can fail to transfer the entire
// file, in which case it's not clear what we're supposed to do.. call it again?
// will it repeatedly fail?
fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
reportProgress(rsrc, localNew.length(), localNew.length());
}
}
}
protected final Proxy _proxy;
}
@@ -1,73 +0,0 @@
//
// Getdown - application installer, patcher and launcher
// Copyright (C) 2004-2018 Getdown authors
// https://github.com/threerings/getdown/blob/master/LICENSE
package com.threerings.getdown.util;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.Proxy;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLDecoder;
import com.threerings.getdown.data.SysProps;
import static java.nio.charset.StandardCharsets.UTF_8;
public final class ConnectionUtil
{
/**
* Opens a connection to a URL, setting the authentication header if user info is present.
* @param proxy the proxy via which to perform HTTP connections.
* @param url the URL to which to open a connection.
* @param connectTimeout if {@code > 0} then a timeout, in seconds, to use when opening the
* connection. If {@code 0} is supplied, the connection timeout specified via system properties
* will be used instead.
* @param readTimeout if {@code > 0} then a timeout, in seconds, to use while reading data from
* the connection. If {@code 0} is supplied, the read timeout specified via system properties
* will be used instead.
*/
public static URLConnection open (Proxy proxy, URL url, int connectTimeout, int readTimeout)
throws IOException
{
URLConnection conn = url.openConnection(proxy);
// configure a connect timeout, if requested
int ctimeout = connectTimeout > 0 ? connectTimeout : SysProps.connectTimeout();
if (ctimeout > 0) {
conn.setConnectTimeout(ctimeout * 1000);
}
// configure a read timeout, if requested
int rtimeout = readTimeout > 0 ? readTimeout : SysProps.readTimeout();
if (rtimeout > 0) {
conn.setReadTimeout(rtimeout * 1000);
}
// If URL has a username:password@ before hostname, use HTTP basic auth
String userInfo = url.getUserInfo();
if (userInfo != null) {
// Remove any percent-encoding in the username/password
userInfo = URLDecoder.decode(userInfo, "UTF-8");
// Now base64 encode the auth info and make it a single line
String encoded = Base64.encodeToString(userInfo.getBytes(UTF_8), Base64.DEFAULT).
replaceAll("\\n","").replaceAll("\\r", "");
conn.setRequestProperty("Authorization", "Basic " + encoded);
}
return conn;
}
/**
* Opens a connection to a http or https URL, setting the authentication header if user info is
* present. Throws a class cast exception if the connection returned is not the right type. See
* {@link #open} for parameter documentation.
*/
public static HttpURLConnection openHttp (
Proxy proxy, URL url, int connectTimeout, int readTimeout) throws IOException
{
return (HttpURLConnection)open(proxy, url, connectTimeout, readTimeout);
}
}