package com.hubilon.proxy; import com.hubilon.proto.ProtobufDecoder; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; import java.nio.file.Path; import java.security.MessageDigest; import java.security.cert.X509Certificate; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.zip.GZIPInputStream; import java.util.zip.InflaterInputStream; /** * Outbound proxy call, mirroring run.ps1's Invoke-ProxyRequest: * self-signed TLS acceptance, no auto-redirect, chunked read with an * inline-base64 ceiling and a hard body cap, sha256 of whatever arrived. */ public final class ProxyClient { public static final int MAX_INLINE_BYTES = 8 * 1024 * 1024; public static final int BODY_CAP_BYTES = 16 * 1024 * 1024; private static final Set RESTRICTED_VIA_PROPERTY = Set.of( "content-length", "connection", "transfer-encoding", "expect", "date", "range", "if-modified-since" ); static { // Allows setRequestProperty("Host", ...) and similar otherwise-restricted // headers to actually reach the wire, matching run.ps1's $req.Host override. System.setProperty("sun.net.http.allowRestrictedHeaders", "true"); } private static SSLContext trustAllContext; private ProxyClient() { } @SuppressWarnings("unchecked") public static Map invoke(Map spec) { return invoke(spec, null); } @SuppressWarnings("unchecked") public static Map invoke(Map spec, Path protoDir) { long start = System.currentTimeMillis(); String method = String.valueOf(spec.getOrDefault("method", "GET")).toUpperCase(); String url = String.valueOf(spec.get("url")); Object headersObj = spec.get("headers"); Map headers = headersObj instanceof Map ? (Map) headersObj : Map.of(); String body = spec.get("body") == null ? "" : String.valueOf(spec.get("body")); int timeoutMs = numberField(spec.get("timeoutMs"), 30000); Map result = new LinkedHashMap<>(); result.put("status", 0); result.put("statusText", ""); result.put("headers", new LinkedHashMap()); result.put("contentType", ""); result.put("isBinary", false); result.put("body", ""); result.put("bodyBase64", null); result.put("bodyTooLarge", false); result.put("bodyBytes", 0); result.put("bodySha256", ""); result.put("elapsedMs", 0); result.put("error", null); HttpURLConnection conn = null; try { URL target = URI.create(url).toURL(); conn = (HttpURLConnection) target.openConnection(); conn.setRequestMethod(method); conn.setInstanceFollowRedirects(false); conn.setConnectTimeout(timeoutMs); conn.setReadTimeout(Math.min(timeoutMs, 15000)); conn.setUseCaches(false); if (conn instanceof HttpsURLConnection https) { https.setSSLSocketFactory(trustAllSslContext().getSocketFactory()); https.setHostnameVerifier(acceptAllHostnames()); } for (Map.Entry entry : headers.entrySet()) { String name = entry.getKey(); String value = entry.getValue() == null ? null : String.valueOf(entry.getValue()); if (name == null || value == null || value.isEmpty()) { continue; } String lower = name.toLowerCase(); if (RESTRICTED_VIA_PROPERTY.contains(lower)) { continue; } conn.setRequestProperty(name, value); } boolean hasBody = method.equals("POST") || method.equals("PUT") || method.equals("PATCH"); if (hasBody) { conn.setDoOutput(true); byte[] payload = body.getBytes(java.nio.charset.StandardCharsets.UTF_8); if (conn.getRequestProperty("Content-Type") == null) { conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); } conn.setFixedLengthStreamingMode(payload.length); conn.connect(); if (payload.length > 0) { try (var os = conn.getOutputStream()) { os.write(payload); } } } else { conn.connect(); } int status = conn.getResponseCode(); String statusText = conn.getResponseMessage(); result.put("status", status); result.put("statusText", statusText == null ? "" : statusText); Map responseHeaders = new LinkedHashMap<>(); for (Map.Entry> entry : conn.getHeaderFields().entrySet()) { if (entry.getKey() == null) { continue; } responseHeaders.put(entry.getKey(), String.join(", ", entry.getValue())); } result.put("headers", responseHeaders); String contentType = conn.getContentType(); result.put("contentType", contentType == null ? "" : contentType); InputStream raw = (status >= 400) ? conn.getErrorStream() : conn.getInputStream(); if (raw == null) { raw = InputStream.nullInputStream(); } String contentEncoding = conn.getContentEncoding(); InputStream decoded = wrapDecoding(raw, contentEncoding); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); boolean truncated = false; byte[] chunk = new byte[65536]; try { int n; while ((n = decoded.read(chunk)) > 0) { if (buffer.size() + n > BODY_CAP_BYTES) { truncated = true; break; } buffer.write(chunk, 0, n); } } finally { decoded.close(); } byte[] bytes = buffer.toByteArray(); result.put("bodyBytes", bytes.length); result.put("bodySha256", sha256Hex(bytes)); Object protobufSpec = spec.get("protobuf"); if (protobufSpec instanceof Map) { Map pb = (Map) protobufSpec; if (truncated) { result.put("protobufError", "Body was truncated before the size cap; cannot decode."); } else { try { String protoFile = pb.get("protoFile") == null ? null : String.valueOf(pb.get("protoFile")); String messageType = pb.get("messageType") == null ? null : String.valueOf(pb.get("messageType")); result.put("protobufJson", ProtobufDecoder.decodeToJson(protoDir, protoFile, messageType, bytes)); } catch (Exception e) { result.put("protobufError", e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); } } } boolean isText = isTextContentType(contentType); if (truncated) { result.put("bodyTooLarge", true); result.put("isBinary", !isText); } else if (isText) { result.put("isBinary", false); result.put("body", new String(bytes, charsetOf(contentType))); } else { result.put("isBinary", true); if (bytes.length <= MAX_INLINE_BYTES) { result.put("bodyBase64", java.util.Base64.getEncoder().encodeToString(bytes)); } else { result.put("bodyTooLarge", true); } } } catch (Exception e) { String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); Throwable cause = e.getCause(); if (cause != null && cause.getMessage() != null) { message = message + " (" + cause.getMessage() + ")"; } result.put("error", message); } finally { if (conn != null) { conn.disconnect(); } result.put("elapsedMs", (int) (System.currentTimeMillis() - start)); } return result; } private static InputStream wrapDecoding(InputStream in, String contentEncoding) throws IOException { if (contentEncoding == null) { return in; } String enc = contentEncoding.toLowerCase(); if (enc.contains("gzip")) { return new GZIPInputStream(in); } if (enc.contains("deflate")) { return new InflaterInputStream(in); } return in; } private static int numberField(Object value, int fallback) { if (value instanceof Number number) { return number.intValue(); } return fallback; } private static java.nio.charset.Charset charsetOf(String contentType) { if (contentType != null) { var matcher = java.util.regex.Pattern.compile("charset\\s*=\\s*\"?([^;\"\\s]+)").matcher(contentType); if (matcher.find()) { try { return java.nio.charset.Charset.forName(matcher.group(1)); } catch (Exception ignored) { // fall through to default } } } return java.nio.charset.StandardCharsets.UTF_8; } private static boolean isTextContentType(String contentType) { if (contentType == null || contentType.isBlank()) { return true; } String ct = contentType.toLowerCase(); if (ct.startsWith("text/")) { return true; } String[] tokens = {"json", "xml", "javascript", "ecmascript", "x-www-form-urlencoded", "csv", "yaml", "plain", "html"}; for (String token : tokens) { if (ct.contains(token)) { return true; } } return false; } private static String sha256Hex(byte[] bytes) throws Exception { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(bytes); StringBuilder sb = new StringBuilder(hash.length * 2); for (byte b : hash) { sb.append(String.format("%02x", b)); } return sb.toString(); } private static synchronized SSLContext trustAllSslContext() throws Exception { if (trustAllContext != null) { return trustAllContext; } TrustManager[] trustAll = new TrustManager[]{new X509TrustManager() { public void checkClientTrusted(X509Certificate[] chain, String authType) { } public void checkServerTrusted(X509Certificate[] chain, String authType) { } public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } }}; SSLContext context = SSLContext.getInstance("TLS"); context.init(null, trustAll, new java.security.SecureRandom()); trustAllContext = context; return context; } private static HostnameVerifier acceptAllHostnames() { return (hostname, session) -> true; } }