| /* | |
| * Copyright (c) 2015, 2025, Oracle and/or its affiliates. All rights reserved. | |
| * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. | |
| * | |
| * This code is free software; you can redistribute it and/or modify it | |
| * under the terms of the GNU General Public License version 2 only, as | |
| * published by the Free Software Foundation. | |
| * | |
| * This code is distributed in the hope that it will be useful, but WITHOUT | |
| * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or | |
| * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License | |
| * version 2 for more details (a copy is included in the LICENSE file that | |
| * accompanied this code). | |
| * | |
| * You should have received a copy of the GNU General Public License version | |
| * 2 along with this work; if not, write to the Free Software Foundation, | |
| * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. | |
| * | |
| * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA | |
| * or visit www.oracle.com if you need additional information or have any | |
| * questions. | |
| */ | |
| import java.net.*; | |
| import java.io.*; | |
| import java.nio.ByteBuffer; | |
| import java.nio.channels.ServerSocketChannel; | |
| import java.nio.channels.SocketChannel; | |
| import java.util.*; | |
| import java.util.concurrent.CopyOnWriteArrayList; | |
| import static java.nio.charset.StandardCharsets.UTF_8; | |
| import static java.nio.charset.StandardCharsets.ISO_8859_1; | |
| import static java.util.Arrays.asList; | |
| import static java.util.stream.Collectors.toList; | |
| /** | |
| * A minimal proxy server that supports CONNECT tunneling. It does not do | |
| * any header transformations. In future this could be added. | |
| * Two threads are created per client connection. So, it's not | |
| * intended for large numbers of parallel connections. | |
| */ | |
| public final class ProxyServer implements Closeable { | |
| // could use the test library here - Platform.isWindows(), | |
| // but it would force all tests that use ProxyServer to | |
| // build it. Let's keep it simple. | |
| static final boolean IS_WINDOWS; | |
| static { | |
| // Parses os.name directly in order to avoid depending on test | |
| // libraries in an auxiliary test class | |
| String osName = System.getProperty("os.name", "unknown"); | |
| IS_WINDOWS = osName.toLowerCase(Locale.ROOT).startsWith("win"); | |
| } | |
| public static boolean isWindows() { | |
| return IS_WINDOWS; | |
| } | |
| ServerSocketChannel listener; | |
| int port; | |
| volatile boolean debug; | |
| private final Credentials credentials; // may be null | |
| private static class Credentials { | |
| private final String name; | |
| private final String password; | |
| private Credentials(String name, String password) { | |
| this.name = name; | |
| this.password = password; | |
| } | |
| public String name() { return name; } | |
| public String password() { return password; } | |
| } | |
| /** | |
| * Create proxy on port (zero means don't care). Call getPort() | |
| * to get the assigned port. | |
| */ | |
| public ProxyServer(Integer port) throws IOException { | |
| this(port, false); | |
| } | |
| public ProxyServer(Integer port, | |
| Boolean debug, | |
| String username, | |
| String password) | |
| throws IOException | |
| { | |
| this(port, debug, new Credentials(username, password)); | |
| } | |
| public ProxyServer(Integer port, | |
| Boolean debug) | |
| throws IOException | |
| { | |
| this(port, debug, null); | |
| } | |
| private ProxyServer(Integer port, Boolean debug, Credentials credentials) | |
| throws IOException | |
| { | |
| this.debug = debug; | |
| listener = ServerSocketChannel.open(); | |
| listener.setOption(StandardSocketOptions.SO_REUSEADDR, false); | |
| listener.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), port)); | |
| this.port = ((InetSocketAddress)listener.getLocalAddress()).getPort(); | |
| this.credentials = credentials; | |
| connections = new CopyOnWriteArrayList<Connection>(); | |
| Thread.ofPlatform().name("ProxyListener").daemon().start(this::run); | |
| } | |
| /** | |
| * Returns the port number this proxy is listening on | |
| */ | |
| public int getPort() { | |
| return port; | |
| } | |
| public InetSocketAddress getProxyAddress() throws IOException { | |
| return (InetSocketAddress)listener.getLocalAddress(); | |
| } | |
| /** | |
| * Shuts down the proxy, probably aborting any connections | |
| * currently open | |
| */ | |
| public void close() throws IOException { | |
| if (debug) System.out.println("Proxy: closing server"); | |
| done = true; | |
| listener.close(); | |
| for (Connection c : connections) { | |
| c.close(); | |
| c.awaitCompletion(); | |
| } | |
| } | |
| final CopyOnWriteArrayList<Connection> connections; | |
| volatile boolean done; | |
| private void run() { | |
| int id = 0; | |
| try { | |
| while (!done) { | |
| SocketChannel s = listener.accept(); | |
| id++; | |
| Connection c = new Connection(s, id); | |
| if (debug) | |
| System.out.println("Proxy: accepted new connection: " + c); | |
| connections.add(c); | |
| c.init(); | |
| } | |
| } catch(Throwable e) { | |
| if (debug && !done) { | |
| System.out.println("Proxy: Fatal error, listener got " + e); | |
| e.printStackTrace(); | |
| } | |
| } | |
| } | |
| /** | |
| * Transparently forward everything, once we know what the destination is | |
| */ | |
| class Connection { | |
| private final int id; | |
| SocketChannel clientSocket, serverSocket; | |
| Thread out, in; | |
| volatile InputStream clientIn, serverIn; | |
| volatile OutputStream clientOut, serverOut; | |
| boolean proxyInClosed; // only accessed from synchronized block | |
| boolean proxyOutClosed; // only accessed from synchronized block | |
| final static int CR = 13; | |
| final static int LF = 10; | |
| Connection(SocketChannel s, int id) throws IOException { | |
| this.id = id; | |
| this.clientSocket= s; | |
| this.clientIn = new BufferedInputStream(s.socket().getInputStream()); | |
| this.clientOut = s.socket().getOutputStream(); | |
| } | |
| byte[] readHeaders(InputStream is) throws IOException { | |
| byte[] outbuffer = new byte[8000]; | |
| int crlfcount = 0; | |
| int bytecount = 0; | |
| int c; | |
| while ((c=is.read()) != -1 && bytecount < outbuffer.length) { | |
| outbuffer[bytecount++] = (byte)c; | |
| if (debug) System.out.write(c); | |
| // were looking for CRLFCRLF sequence | |
| if (c == CR || c == LF) { | |
| switch(crlfcount) { | |
| case 0: | |
| if (c == CR) crlfcount ++; | |
| break; | |
| case 1: | |
| if (c == LF) crlfcount ++; | |
| break; | |
| case 2: | |
| if (c == CR) crlfcount ++; | |
| break; | |
| case 3: | |
| if (c == LF) crlfcount ++; | |
| break; | |
| } | |
| } else { | |
| crlfcount = 0; | |
| } | |
| if (crlfcount == 4) { | |
| break; | |
| } | |
| } | |
| byte[] ret = new byte[bytecount]; | |
| System.arraycopy(outbuffer, 0, ret, 0, bytecount); | |
| return ret; | |
| } | |
| boolean running() { | |
| return out.isAlive() || in.isAlive(); | |
| } | |
| private volatile boolean closing; | |
| public synchronized void close() throws IOException { | |
| closing = true; | |
| if (debug) | |
| System.out.println("Proxy: closing connection {" + this + "}"); | |
| if (serverSocket != null) | |
| serverSocket.close(); | |
| if (clientSocket != null) | |
| clientSocket.close(); | |
| } | |
| public void awaitCompletion() { | |
| try { | |
| if (in != null) | |
| in.join(); | |
| if (out!= null) | |
| out.join(); | |
| } catch (InterruptedException e) { } | |
| } | |
| int findCRLF(byte[] b) { | |
| for (int i=0; i<b.length-1; i++) { | |
| if (b[i] == CR && b[i+1] == LF) { | |
| return i; | |
| } | |
| } | |
| return -1; | |
| } | |
| // Checks credentials in the request against those allowable by the proxy. | |
| private boolean authorized(Credentials credentials, | |
| List<String> requestHeaders) { | |
| List<String> authorization = requestHeaders.stream() | |
| .filter(n -> n.toLowerCase(Locale.US).startsWith("proxy-authorization")) | |
| .collect(toList()); | |
| if (authorization.isEmpty()) | |
| return false; | |
| if (authorization.size() != 1) { | |
| throw new IllegalStateException("Authorization unexpected count:" + authorization); | |
| } | |
| String value = authorization.get(0).substring("proxy-authorization".length()).trim(); | |
| if (!value.startsWith(":")) | |
| throw new IllegalStateException("Authorization malformed: " + value); | |
| value = value.substring(1).trim(); | |
| if (!value.startsWith("Basic ")) | |
| throw new IllegalStateException("Authorization not Basic: " + value); | |
| value = value.substring("Basic ".length()); | |
| String values = new String(Base64.getDecoder().decode(value), UTF_8); | |
| int sep = values.indexOf(':'); | |
| if (sep < 1) { | |
| throw new IllegalStateException("Authorization no colon: " + values); | |
| } | |
| String name = values.substring(0, sep); | |
| String password = values.substring(sep + 1); | |
| if (name.equals(credentials.name()) && password.equals(credentials.password())) | |
| return true; | |
| return false; | |
| } | |
| public void init() { | |
| try { | |
| byte[] buf; | |
| String host; | |
| List<String> headers; | |
| boolean authorized = false; | |
| while (true) { | |
| buf = readHeaders(clientIn); | |
| if (findCRLF(buf) == -1) { | |
| if (debug) | |
| System.out.println("Proxy: no CRLF closing, buf contains:[" | |
| + new String(buf, ISO_8859_1) + "]" ); | |
| close(); | |
| return; | |
| } | |
| headers = asList(new String(buf, ISO_8859_1).split("\r\n")); | |
| host = findFirst(headers, "host"); | |
| // check authorization credentials, if required by the server | |
| if (credentials != null) { | |
| if (!authorized(credentials, headers)) { | |
| boolean shouldClose = shouldCloseAfter407(headers); | |
| var closestr = shouldClose ? "Connection: close\r\n" : ""; | |
| String resp = "HTTP/1.1 407 Proxy Authentication Required\r\n" + | |
| "Content-Length: 0\r\n" + closestr + | |
| "Proxy-Authenticate: Basic realm=\"proxy realm\"\r\n\r\n"; | |
| clientSocket.setOption(StandardSocketOptions.TCP_NODELAY, true); | |
| clientSocket.setOption(StandardSocketOptions.SO_LINGER, 2); | |
| var buffer = ByteBuffer.wrap(resp.getBytes(ISO_8859_1)); | |
| clientSocket.write(buffer); | |
| if (debug) { | |
| var linger = clientSocket.getOption(StandardSocketOptions.SO_LINGER); | |
| var nodelay = clientSocket.getOption(StandardSocketOptions.TCP_NODELAY); | |
| System.out.printf("Proxy: unauthorized; 407 sent (%s/%s), linger: %s, nodelay: %s%n", | |
| buffer.position(), buffer.position() + buffer.remaining(), linger, nodelay); | |
| } | |
| if (shouldClose) { | |
| closeConnection(); | |
| return; | |
| } | |
| continue; | |
| } | |
| authorized = true; | |
| break; | |
| } else { | |
| break; | |
| } | |
| } | |
| int p = findCRLF(buf); | |
| String cmd = new String(buf, 0, p, ISO_8859_1); | |
| String[] params = cmd.split(" "); | |
| if (params[0].equals("CONNECT")) { | |
| doTunnel(params[1]); | |
| } else { | |
| // TODO: | |
| // this does not really work as it should: it also establishes | |
| // a tunnel (proxyCommon). So it works as long as the client only | |
| // sends a single plain request through the proxy - as all | |
| // other requests would otherwise be tunneled to the | |
| // server identified in the first request. | |
| // It seems enough for our purpose for now, though. | |
| // Fixing this would imply dealing with Content-Length, Transfer-Encoding, | |
| // etc, both when writing to and reading from the server. | |
| doProxy(params[1], cmd, headers, host, authorized); | |
| } | |
| } catch (Throwable e) { | |
| if (debug) { | |
| System.out.println("Proxy: " + e); | |
| e.printStackTrace(); | |
| } | |
| try {close(); } catch (IOException e1) {} | |
| } | |
| } | |
| String findFirst(List<String> headers, String key) { | |
| var h = key.toLowerCase(Locale.ROOT) + ": "; | |
| return headers.stream() | |
| .filter((s) -> s.toLowerCase(Locale.ROOT).startsWith(h)) | |
| .findFirst() | |
| .map((s) -> s.substring(h.length())) | |
| .map(String::trim) | |
| .orElse(null); | |
| } | |
| private long drain(SocketChannel socket) throws IOException { | |
| boolean isBlocking = socket.isBlocking(); | |
| if (isBlocking) { | |
| socket.configureBlocking(false); | |
| } | |
| try { | |
| ByteBuffer buffer = ByteBuffer.allocate(1024); | |
| int read; | |
| long drained = 0; | |
| while ((read = socket.read(buffer)) > 0) { | |
| drained += read; | |
| buffer.position(0); | |
| buffer.limit(buffer.capacity()); | |
| } | |
| return drained; | |
| } finally { | |
| if (isBlocking) { | |
| socket.configureBlocking(true); | |
| } | |
| } | |
| } | |
| void closeConnection() throws IOException { | |
| if (debug) { | |
| var linger = clientSocket.getOption(StandardSocketOptions.SO_LINGER); | |
| var nodelay = clientSocket.getOption(StandardSocketOptions.TCP_NODELAY); | |
| System.out.printf("Proxy: closing connection id=%s, linger: %s, nodelay: %s%n", | |
| id, linger, nodelay); | |
| } | |
| long drained = drain(clientSocket); | |
| if (debug) { | |
| System.out.printf("Proxy: drained: %s%n", drained); | |
| } | |
| clientSocket.shutdownOutput(); | |
| try { | |
| // On windows, we additionally need to delay before | |
| // closing the socket. Otherwise we get a reset on the | |
| // client side (The connection was aborted by a software | |
| // on the host machine). | |
| // Delay 500ms before actually closing the socket | |
| if (isWindows()) Thread.sleep(500); | |
| } catch (InterruptedException x) { | |
| // OK | |
| } | |
| clientSocket.shutdownInput(); | |
| close(); | |
| } | |
| // If the client sends a request body we will need to close the connection | |
| // otherwise, we can keep it open. | |
| private boolean shouldCloseAfter407(List<String> headers) throws IOException { | |
| var cmdline = headers.get(0); | |
| int m = cmdline.indexOf(' '); | |
| var method = (m > 0) ? cmdline.substring(0, m) : null; | |
| var nobody = List.of("GET", "HEAD"); | |
| var te = findFirst(headers, "transfer-encoding"); | |
| if (te != null) { | |
| // processing transfer encoding not implemented | |
| if (debug) { | |
| System.out.println("Proxy: transfer-encoding with 407, closing connection"); | |
| } | |
| return true; // should close | |
| } | |
| var cl = findFirst(headers, "content-length"); | |
| int n = -1; | |
| if (cl == null) { | |
| if (nobody.contains(method)) { | |
| n = 0; | |
| System.out.printf("Proxy: no content length for %s, assuming 0%n", method); | |
| } else { | |
| System.out.printf("Proxy: no content-length for %s, closing connection%n", method); | |
| return true; | |
| } | |
| } else { | |
| try { | |
| n = Integer.parseInt(cl); | |
| if (debug) { | |
| System.out.printf("Proxy: content-length: %d%n", n); | |
| } | |
| } catch (NumberFormatException x) { | |
| if (debug) { | |
| System.out.println("Proxy: bad content-length, closing connection"); | |
| System.out.println("Proxy: \tcontent-length: " + cl); | |
| System.out.println("Proxy: \theaders: " + headers); | |
| System.out.println("Proxy: \t" + x); | |
| } | |
| return true; // should close | |
| } | |
| } | |
| if (n > 0) { | |
| if (debug) { | |
| System.out.println("Proxy: request body with 407, closing connection"); | |
| } | |
| return true; // should close | |
| } | |
| if (n == 0) { | |
| var available = clientIn.available(); | |
| var drained = drain(clientSocket); | |
| if (drained > 0 || available > 0) { | |
| if (debug) { | |
| System.out.printf("Proxy: unexpected bytes (%d) with 407, closing connection%n", | |
| drained + available); | |
| } | |
| return true; // should close | |
| } | |
| // can keep open: CL=0 or no CL and GET or HEAD | |
| return false; | |
| } else { | |
| if (debug) { | |
| System.out.println("Proxy: possible body with 407, closing connection"); | |
| } | |
| return true; // should close | |
| } | |
| } | |
| void doProxy(String dest, String cmdLine, List<String> headers, String host, boolean authorized) | |
| throws IOException | |
| { | |
| try { | |
| URI uri = new URI(dest); | |
| if (!uri.isAbsolute()) { | |
| if (host == null) { | |
| throw new IOException("request URI not absolute"); | |
| } else { | |
| uri = new URI("http://" + host + dest); | |
| } | |
| } | |
| if (debug) System.out.printf("Proxy: uri=%s%n", uri); | |
| dest = uri.getAuthority(); | |
| // now extract the path from the URI and recreate the cmd line | |
| int sp = cmdLine.indexOf(' '); | |
| String method = cmdLine.substring(0, sp); | |
| cmdLine = method + " " + uri.getPath() + " HTTP/1.1"; | |
| commonInit(dest, 80); | |
| OutputStream sout; | |
| synchronized (this) { | |
| if (closing) return; | |
| sout = serverOut; | |
| } | |
| // might fail if we're closing but we don't care. | |
| byte[] CRLF = new byte[] { (byte) '\r', (byte) '\n'}; | |
| sout.write(cmdLine.getBytes(ISO_8859_1)); | |
| sout.write(CRLF); | |
| if (debug) System.out.printf("Proxy Forwarding: %s%n", cmdLine); | |
| for (int l=1; l<headers.size(); l++) { | |
| var s = headers.get(l); | |
| if (!authorized || !s.toLowerCase(Locale.ROOT).startsWith("proxy-authorization")) { | |
| sout.write(s.getBytes(ISO_8859_1)); | |
| sout.write(CRLF); | |
| if (debug) System.out.printf("Proxy Forwarding: %s%n", s); | |
| } else { | |
| if (debug) System.out.printf("Proxy Skipping: %s%n", s); | |
| } | |
| } | |
| sout.write(CRLF); | |
| if (debug) System.out.printf("Proxy Forwarding: %n"); | |
| // This will now establish a tunnel :-( | |
| proxyCommon(debug); | |
| } catch (URISyntaxException e) { | |
| throw new IOException(e); | |
| } | |
| } | |
| synchronized void commonInit(String dest, int defaultPort) throws IOException { | |
| if (closing) return; | |
| int port; | |
| String[] hostport = dest.split(":"); | |
| if (hostport.length == 1) { | |
| port = defaultPort; | |
| } else { | |
| port = Integer.parseInt(hostport[1]); | |
| } | |
| if (debug) | |
| System.out.printf("Proxy: connecting to (%s/%d)\n", hostport[0], port); | |
| serverSocket = SocketChannel.open(); | |
| serverSocket.connect(new InetSocketAddress(hostport[0], port)); | |
| serverOut = serverSocket.socket().getOutputStream(); | |
| serverIn = new BufferedInputStream(serverSocket.socket().getInputStream()); | |
| } | |
| synchronized void proxyCommon(boolean log) throws IOException { | |
| if (closing) return; | |
| out = new Thread(() -> { | |
| try { | |
| byte[] bb = new byte[8000]; | |
| int n; | |
| int body = 0; | |
| while ((n = clientIn.read(bb)) != -1) { | |
| serverOut.write(bb, 0, n); | |
| body += n; | |
| if (log) | |
| System.out.printf("Proxy Forwarding [request body]: total %d%n", body); | |
| } | |
| closeClientIn(); | |
| } catch (IOException e) { | |
| if (!closing && debug) { | |
| System.out.println("Proxy: " + e); | |
| e.printStackTrace(); | |
| } | |
| } | |
| }); | |
| in = new Thread(() -> { | |
| try { | |
| byte[] bb = new byte[8000]; | |
| int n; | |
| int resp = 0; | |
| while ((n = serverIn.read(bb)) != -1) { | |
| clientOut.write(bb, 0, n); | |
| resp += n; | |
| if (log) System.out.printf("Proxy Forwarding [response]: %s%n", new String(bb, 0, n, UTF_8)); | |
| if (log) System.out.printf("Proxy Forwarding [response]: total %d%n", resp); | |
| } | |
| closeClientOut(); | |
| } catch (IOException e) { | |
| if (!closing && debug) { | |
| System.out.println("Proxy: " + e); | |
| e.printStackTrace(); | |
| } | |
| } | |
| }); | |
| out.setName("Proxy-outbound"); | |
| out.setDaemon(true); | |
| in.setDaemon(true); | |
| in.setName("Proxy-inbound"); | |
| out.start(); | |
| in.start(); | |
| } | |
| void doTunnel(String dest) throws IOException { | |
| if (closing) return; // no need to go further. | |
| commonInit(dest, 443); | |
| // might fail if we're closing, but we don't care. | |
| clientOut.write("HTTP/1.1 200 OK\r\n\r\n".getBytes()); | |
| proxyCommon(false); | |
| } | |
| synchronized void closeClientIn() throws IOException { | |
| closing = true; | |
| proxyInClosed = true; | |
| clientSocket.shutdownInput(); | |
| serverSocket.shutdownOutput(); | |
| if (proxyOutClosed) { | |
| serverSocket.close(); | |
| clientSocket.close(); | |
| } | |
| } | |
| synchronized void closeClientOut() throws IOException { | |
| closing = true; | |
| proxyOutClosed = true; | |
| serverSocket.shutdownInput(); | |
| clientSocket.shutdownOutput(); | |
| if (proxyInClosed) { | |
| serverSocket.close(); | |
| clientSocket.close(); | |
| } | |
| } | |
| @Override | |
| public String toString() { | |
| return "Proxy connection " + id + ", client sock:" + clientSocket; | |
| } | |
| } | |
| public static void main(String[] args) throws Exception { | |
| int port = Integer.parseInt(args[0]); | |
| boolean debug = args.length > 1 && args[1].equals("-debug"); | |
| System.out.println("Debugging : " + debug); | |
| try (ProxyServer ps = new ProxyServer(port, debug)) { | |
| System.out.println("Proxy server listening on port " + ps.getPort()); | |
| while (true) { | |
| Thread.sleep(5000); | |
| } | |
| } | |
| } | |
| } |